Skip to content

Create, append, and query your first table

This tutorial creates a timestamp-indexed table, appends one Parquet file that contains two symbols, and queries both identities through one logical table.

Before you start, install and verify the Python package.

Run the example

Run this example from an empty working directory. It creates a table at ./my_table and prints four rows.

from __future__ import annotations

from pathlib import Path

import pyarrow as pa
import pyarrow.parquet as pq

import timeseries_table_format as ttf


def _write_tiny_prices_parquet(path: Path) -> None:
    table = pa.table(
        {
            "ts": pa.array(
                [0, 0, 3_600 * 1_000_000, 3_600 * 1_000_000],
                type=pa.timestamp("us"),
            ),
            "exchange_id": pa.array([1, 2, 1, 2], type=pa.int32()),
            "symbol": pa.array(["NVDA", "AAPL", "NVDA", "AAPL"], type=pa.string()),
            "close": pa.array([10.0, 20.0, 11.0, 21.0], type=pa.float64()),
        }
    )
    pq.write_table(table, str(path))


def run(*, table_root: Path) -> pa.Table:
    table_root.mkdir(parents=True, exist_ok=True)

    tbl = ttf.TimeSeriesTable.create(
        table_root=str(table_root),
        index_column="ts",
        index_type="timestamp",
        index_granularity="1h",
        entity_columns=["exchange_id", "symbol"],
        timezone=None,
    )

    seg_path = table_root / "incoming" / "prices.parquet"
    seg_path.parent.mkdir(parents=True, exist_ok=True)
    _write_tiny_prices_parquet(seg_path)

    parquet_file = pq.ParquetFile(seg_path)
    tbl.append(
        pa.RecordBatchReader.from_batches(
            parquet_file.schema_arrow,
            parquet_file.iter_batches(),
        )
    )

    sess = ttf.Session()
    sess.register_tstable("prices", str(table_root))
    before = sess.sql(
        """
        select ts, exchange_id, symbol, close
        from prices
        order by exchange_id, symbol, ts
        """
    )

    report = tbl.optimize()
    assert report.source_segments_replaced == 1
    assert report.replacement_segments_written == 2
    assert report.distinct_identities_materialized == 2
    assert report.rows_read == report.rows_written == 4
    assert not report.no_op

    repeated = tbl.optimize()
    assert repeated.no_op
    assert repeated.starting_version == repeated.committed_version
    assert repeated.committed_version == report.committed_version

    sess = ttf.Session()
    sess.register_tstable("prices", str(table_root))
    after = sess.sql(
        """
        select ts, exchange_id, symbol, close
        from prices
        order by exchange_id, symbol, ts
        """
    )
    assert after.equals(before)
    return after


def main() -> None:
    out = run(table_root=Path("./my_table"))
    print(out)


if __name__ == "__main__":
    main()

1. Create the table

TimeSeriesTable.create(...) initializes the table root and its metadata:

  • index_column="ts" selects the ascending timestamp column.
  • index_granularity="1h" divides the timestamp domain into one-hour logical intervals.
  • entity_columns=["exchange_id", "symbol"] tracks coverage independently for each composite identity.

This means NVDA and AAPL can use the same hour without conflicting. Each complete identity may have at most one row in that hour. The entity columns are an ordered identity definition, not instructions to create one table per entity. exchange_id is an Arrow Int32 column, so the example also shows a numeric identity component.

2. Append a Parquet segment

The example opens the Parquet file lazily as a RecordBatchReader and passes it to append(...). Append consumes the batches incrementally and writes one table-owned segment. The source contains both NVDA and AAPL rows in the same hourly intervals; a segment does not need to contain only one identity. The Parquet ts column must be an Arrow timestamp because that is the index type stored in the table metadata.

Append returns an AppendReport containing the committed version, generated segment path, row and row-group counts, file size, and effective writer settings.

If another file uses an existing interval for NVDA, the append raises IndexIntervalOverlapError. AAPL may independently use that same interval. Two NVDA rows in the same incoming interval raise DuplicateIndexIntervalError.

Run the example once

Running the example again fails because ./my_table already contains a table. Delete that directory before repeating the tutorial.

3. Optimize the entity layout

optimize() explicitly rewrites each mixed source segment into one replacement segment per complete identity. In this example, the source segment becomes one NVDA segment and one AAPL segment inside the same logical table.

Optimization is optional. It preserves the logical rows, schema, and per-entity coverage. The returned OptimizeReport records the versions, source and replacement segment counts, distinct identities, row counts, and whether the operation was a no-op.

Calling optimize() again in the example returns a successful no-op. Its starting and committed versions are equal, so it does not create another table version. Replaced source files may remain on disk until a future vacuum operation removes unreferenced files.

This operation is specific to entity layout. It does not combine small files or accept a target file size.

4. Query with SQL

Session provides the DataFusion SQL engine. The example registers the table as prices before and after optimization. It verifies that both queries return the same rows, then returns the optimized result as a pyarrow.Table.

Both entity columns remain normal SQL columns. You can use WHERE exchange_id = 1 AND symbol = 'NVDA' to select one identity or group by both columns to calculate independent aggregates.

You now have a locally queryable table root that can accept more non-overlapping Parquet segments.

Next, learn how to append files incrementally.