Skip to content

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

df.pivot(index, columns, values)

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.

import keyten as kt

df = kt.DataFrame([kt.Series.int("v", [1])])
try:
    df.column("missing")
except kt.SchemaError:
    pass
else:
    raise AssertionError("expected SchemaError")