Series and DataFrame
Series builds one named, typed column; DataFrame assembles Series into a table. Both are eager — no plan is involved until .lazy(). A Series and its published blocks are immutable, while a DataFrame can atomically replace its table through explicit mutation methods. Values encode into compressed blocks the moment a Series is constructed; there is no separate build step.
Constructing Series
Series.int(name, values) # Int, from ints
Series.float(name, values) # Float, from floats
Series.bool(name, values) # Bool, from bools
Series.string(name, values) # Str, from strings
Series.time(name, values) # Time, from nanoseconds since midnight
Series.date(name, values) # Date, from datetime.date
Series.timestamp(name, values) # Timestamp, from datetime.datetime
Every constructor accepts None entries as nulls. There is no untyped constructor — the kind is the constructor you called. date and timestamp columns exist as first-class kinds (days since 1970-01-01 and nanoseconds since the epoch); besides Series.date/Series.timestamp, they also arrive from file scans and cast. Series.date rejects datetime.datetime values (it subclasses date, so the mistake is easy to make and worth catching); use Series.timestamp for those. Series.timestamp rejects timezone-aware values with TypeError — Keyten's temporals are naive (a plain date carries no tzinfo, so the check does not apply to Series.date).
import keyten as kt
s = kt.Series.float("score", [9.5, None, 3.0])
assert (s.name, s.dtype, len(s)) == ("score", "float", 3)
assert s.to_list() == [9.5, None, 3.0]
import keyten as kt
from datetime import date, datetime
df = kt.DataFrame([
kt.Series.date("d", [date(2026, 1, 5), None]),
kt.Series.timestamp("ts", [datetime(2026, 1, 5, 9, 30), None]),
])
assert df.column("d").dtype == "date"
assert df.column("ts").dtype == "timestamp"
Constructing and inspecting DataFrames
DataFrame(columns) # from a list of Series
DataFrame.read_native(path) # eager zero-copy open of a native table
DataFrame.read_parquet(path) # eager read of a Parquet file or directory
df.columns # names, in order
df.shape # (rows, columns)
df.column(name)
df.to_dict() # {name: values}, None for nulls
df.lazy() # start a query
Ragged column lengths or duplicate names raise at construction. column returns a cheap reference-counted handle, not a copy. Printing a frame renders a box-drawn table sized to the terminal, eliding middle rows and columns of a large frame; nulls print as the literal text null.
import keyten as kt
df = kt.DataFrame([
kt.Series.int("id", [1, 2, 3]),
kt.Series.string("name", ["ann", "bo", "cy"]),
])
assert df.shape == (3, 2)
assert df.column("name").to_list() == ["ann", "bo", "cy"]
Reshaping
Long to wide: one output row per distinct index value and one output column per distinct columns value — both in first-appearance order, the column's text becoming the column name. Cells come from values; a duplicate (index, column) pair keeps the last value, and missing cells are null. pivot is eager because its schema depends on the data.
import keyten as kt
long = kt.DataFrame([
kt.Series.string("sym", ["a", "a", "b"]),
kt.Series.string("field", ["bid", "ask", "bid"]),
kt.Series.float("px", [1.0, 2.0, 9.0]),
])
wide = long.pivot("sym", "field", "px")
assert wide.columns == ["sym", "bid", "ask"]
assert wide.to_dict()["ask"] == [2.0, None]
Rows
df.unique(subset=None) # distinct rows over subset (all columns by default)
df.drop_nulls(subset=None) # drop rows with a null in subset (all columns by default)
df.head(n=5) # first n rows
df.tail(n=5) # last n rows
unique keeps the first row seen for each distinct key in subset, along with whatever non-subset values that row happened to carry; row order is otherwise unspecified. drop_nulls with no subset drops a row with a null anywhere; naming columns narrows which ones count, and an empty list raises. Both are eager and route through a lazy plan internally, so they get the same optimizer treatment as any query.
import keyten as kt
df = kt.DataFrame([
kt.Series.string("sym", ["a", "a", "b"]),
kt.Series.int("qty", [1, None, 3]),
])
assert df.unique(["sym"]).shape == (2, 2)
assert df.drop_nulls().shape == (2, 2)
assert df.head(2).column("sym").to_list() == ["a", "a"]
Persistence
df.write_native(path) # whole table -> directory, atomically
df.append_native(path) # add rows; schema must match exactly
df.write_csv(path) # CSV in the forms scan_csv reads back
write_native stages, fsyncs, and atomically renames — a crash leaves either the old table or the new one, never a mix. append_native writes data before it swaps the manifest, so a crash mid-append leaves the previous table intact. The formats and their guarantees are covered in input, output, and runtime.
In-place mutation
df.append(other) # append exact-schema rows
df.upsert(other, on=keys) # replace matching keys, append new keys
df.update(where=expr, set={...}) # replace values on matching rows
df.delete(where=expr) # remove matching rows
These methods mutate the DataFrame handle, not shared blocks. A LazyFrame created before the call keeps its original snapshot. append and upsert require identical schemas; upsert rejects null incoming keys. All expressions in one update read the pre-update frame.
The mutation tutorial works through the exact key, duplicate, snapshot, and persistence semantics.
Errors
All engine errors derive from KeytenError: SchemaError for name and type contract violations, ComputeError for failures in the data itself (shape mismatches, malformed CSV, a corrupt file), CancelledError for a cancelled query. File-system problems raise OSError; exceeding memory raises MemoryError. Argument-form mistakes (wrong Python types) raise plain TypeError at call time.