| name | storage |
| description | Authoring guideline for DuckPQ/DuckTable-based Parquet storage in Quool. Covers DuckPQ initialization, table discovery and registration, CRUD operations (upsert/select/load/query), cross-table JOIN queries using the <table><sep><column> pattern, DataFrame attachment, and parquet compaction. Encourages using DuckPQ as the top-level database interface rather than operating on DuckTable in isolation.
|
| author | quool team |
| version | 0.0.1 |
| triggers | ["use DuckPQ storage","store data in parquet","query parquet files","upsert data into parquet","load parquet data","attach dataframe to duckdb","compact parquet files"] |
Storage Skill
Authoring guideline: read this entire document before writing any code. Every section is load-bearing.
Role
You are a data engineering specialist. Your job is to produce correct, efficient DuckPQ/DuckTable storage implementations from a user's informal specification. Follow the workflow below step by step. Do not skip steps, do not make assumptions not supported by the user's request, and do not hardcode values the user did not specify.
Core Architecture
Quool provides two layers for Parquet-backed storage:
DuckPQ # Top-level database manager — coordinates multiple DuckTable instances
└── DuckTable # Per-table Parquet directory handler (Hive-partitioned)
DuckPQ is the encouraged interface. It wraps a single DuckDB connection shared across all tables, auto-discovers table directories under a root path, and exposes a unified CRUD API. You should rarely need to instantiate DuckTable directly — DuckPQ.upsert, DuckPQ.select, and DuckPQ.load cover most use cases.
DuckTable is the underlying building block. Each DuckTable instance manages one Parquet directory (one table) and exposes select, upsert, compact, and pivot operations. It is designed to be used internally by DuckPQ, not as a standalone entry point.
Step 1 — Initialize DuckPQ
from quool import DuckPQ
db = DuckPQ(
root_path="/path/to/database",
database=None,
config=None,
threads=4,
)
root_path: Each immediate subdirectory under root_path is treated as one table.
database: None → in-memory DuckDB (ephemeral); a path string → persistent .duckdb file; a DuckDBPyConnection → reuse an existing connection (DuckPQ will not close it).
- DuckPQ is a context manager — prefer
with DuckPQ(...) as db: to ensure the connection is closed.
with DuckPQ(root_path="/path/to/database") as db:
pass
Step 2 — Register Tables
Tables are discovered automatically when you call register():
db.register()
db.register(name="kline")
register() scans the filesystem under root_path and creates a DuckTable for each subdirectory found. After registration, tables are accessible via db.tables (a dict[str, DuckTable]).
You can also check which table directories exist but are not yet registered:
unregistered = db.registrable()
Step 3 — Create / Insert / Upsert Data
Use upsert() to insert new rows or update existing rows based on primary keys:
db.upsert(
table="kline",
df=df_input,
keys=["code", "date"],
partition_by=["date"],
)
Behavior:
- If the table directory does not exist yet, it is created automatically.
- If rows with the same
keys already exist, they are replaced with the incoming rows.
- If
partition_by is set, data is written as Hive-partitioned parquet files under root_path/table/.
- If
partition_by is None, a single data_0.parquet file is written.
Requirements on df_input:
- Must not contain duplicate rows based on
keys.
- Column names must match what the table expects (no extra columns unless the table schema allows them).
- DO NOT put
keys column in DataFrame index, it won't be recognized.
df_input.columns = ["code", "date", "open", "high", "low", "close", "volume"]
db.upsert(table="kline", df=df_input, keys=["code", "date"], partition_by=["date"])
Step 4 — Read / Query Data
select() — single-table query
df = db.select(
table="kline",
columns="*",
where="date = '2024-01-01'",
params=None,
group_by=None,
having=None,
order_by="date",
limit=1000,
offset=None,
distinct=False,
)
load() — cross-table query via <table><sep><column> pattern
Use load() when your query spans multiple tables. Columns are specified as "table/column" paths:
df = db.load(
columns=["kline/close", "kline/volume", "financial/pe"],
where="kline/date = '2024-01-01'",
params=None,
group_by=None,
having=None,
order_by=None,
limit=None,
offset=None,
distinct=False,
sep="/",
)
The <table><sep><column> pattern:
- Every column spec is
"table_name<sep>column_name" (e.g., "kline/close" with sep="/").
load() parses the column specs, determines which tables are needed, and automatically builds a LEFT JOIN query using date and code as join keys.
- Supports
"table/column AS alias" for renaming in the result.
df = db.load(
columns=["kline/close", "kline/volume"],
where="code = '000001'",
)
df = db.load(
columns=["kline/close", "quotes/volume"],
where="kline/date = '2024-01-01'",
)
query() / execute() — raw SQL
For complex queries that load() cannot express:
df = db.query("SELECT * FROM kline WHERE date = '2024-01-01' LIMIT 100")
rel = db.execute("SELECT * FROM kline WHERE date = '2024-01-01'")
attach() — register a DataFrame temporarily
Use attach() to expose a pandas DataFrame as a DuckDB relation (view-like) or a temporary table, without writing to disk:
db.attach(
name="temp_signal",
df=df_signal,
replace=True,
materialize=False,
)
result = db.query("SELECT * FROM temp_signal WHERE signal > 0")
attach() is useful for intermediate results, signal DataFrames, or any data that does not need to be persisted to Parquet.
Step 5 — Inspect Schema and Table Metadata
db.tables.keys()
db.tables["kline"].columns
db.tables["kline"].schema
db.tables["kline"].empty
db.registrable()
Step 6 — Compact Parquet Files
Over time, many small parquet files accumulate under partitioned table directories. Use compact() to merge them:
db.compact(
table="kline",
compression="zstd",
max_workers=8,
engine="pyarrow",
)
Returns a list of relative partition paths that were compacted.
Step 7 — Close the Connection
db.close()
Or use the context manager for automatic cleanup:
with DuckPQ(root_path="/path/to/database") as db:
pass
DuckTable Direct Usage (Rare)
Only use DuckTable directly when you need per-table operations not exposed by DuckPQ (e.g., dpivot, ppivot):
from quool import DuckPQ
db = DuckPQ(root_path="/path/to/database")
table = db.tables["kline"]
df = table.select(columns="*", where="date = '2024-01-01'")
table.upsert(df=df_new, keys=["code", "date"], partition_by=["date"])
table.refresh()
table.compact()
pivot_df = table.dpivot(
index="date",
columns="code",
values="close",
aggfunc="first",
where="date >= '2024-01-01'",
)
wide_df = table.ppivot(
index="date",
columns="code",
values="volume",
aggfunc="sum",
)
Decision Tree — Choosing the Right Method
Need to...
│
├─ Initialize a database
│ └─ `DuckPQ(root_path=...)` — prefer context manager
│
├─ Upsert DataFrame to Parquet
│ └─ `db.upsert(table, df, keys, partition_by)`
│
├─ Query a single table
│ ├─ Simple filter/sort/limit → `db.select(table, columns, where, ...)`
│ └─ Complex aggregation → `db.query("SELECT ... FROM table ...")``
│
├─ Query multiple tables (cross-table JOIN)
│ └─ `db.load(["table1/col1", "table2/col2"], where=...)`
│
├─ Register a temporary DataFrame in DuckDB
│ └─ `db.attach(name, df, materialize=False)`
│
├─ Get DuckDB relation for chaining
│ └─ `db.execute("SELECT ...")` → DuckDBPyRelation
│
├─ Inspect table metadata
│ ├─ List tables → `db.tables.keys()`
│ ├─ List columns → `db.tables[name].columns`
│ ├─ Show schema → `db.tables[name].schema`
│ └─ Check if empty → `db.tables[name].empty`
│
└─ Merge small parquet files
└─ `db.compact(table, compression, max_workers)`
The <table><sep><column> Path Pattern (load / cross-table)
Every column spec in load() follows this format:
<table> <sep> <column>
| Component | Meaning |
|---|
table | Table (directory) name under root_path |
sep | Separator — defaults to "/" |
column | Column name stored in that table's parquet files |
The path is split by sep into exactly two parts: [table, column]. No subdirectory nesting is supported. If your data is organized under a path like daily/ohlcv/close, the table name would be daily and the column would be ohlcv/close — but this is not recommended. Keep table names simple.
Example: Querying close and volume from kline table, and pe from financial table:
df = db.load(
columns=["kline/close", "kline/volume", "financial/pe"],
where="kline/date >= '2024-01-01' AND kline/date <= '2024-12-31'",
sep="/",
)
Critical Gotchas
-
load() auto-determines JOIN keys. The method uses datetime (cast to TIMESTAMP) and code as the automatic JOIN keys across tables. Both columns must exist in all tables participating in the cross-table query.
-
upsert() deduplicates by keys. If the incoming DataFrame has duplicate rows based on the key columns, upsert() raises a ValueError. Deduplicate before calling.
-
partition_by creates Hive partitioning. When partition_by=["date"] is set, the parquet files are written under root_path/table/date=2024-01-01/ directories. This is the standard Hive-style partitioning scheme that DuckDB's parquet_scan with HIVE_PARTITIONING=1 reads automatically.
-
attach() is ephemeral. DataFrames registered via attach() are only available for the lifetime of the DuckDB connection. They are not written to parquet and do not persist after close().
5 register() is needed for existing tables. If you start DuckPQ with a root_path that already contains table directories, you must call db.register() (or db.register(name="specific_table")) to create the DuckTable views before querying.
-
DuckTable.refresh() after external file changes. If you manually add, delete, or modify parquet files outside of DuckTable's API, call table.refresh() to update the DuckDB view.
-
database=None means in-memory. The default database=None creates a :memory: DuckDB. Data written via upsert() persists to parquet files under root_path, but the DuckDB metadata/views are lost when the connection closes. Use database="/path/to/file.duckdb" for persistent metadata.
-
select() vs load() for single tables. select() operates on one table at a time and uses direct SQL. load() uses the <table>/<column> shorthand and can handle both single and multi-table queries. Prefer select() for simple single-table queries — it is more explicit.
Workflow Checklist
Before returning the final implementation, verify: