Quickstart
Five minutes from here you’ll have a real table, rows in it, and working SQL. There is nothing to enable — tables are implicit per bucket. Create the bucket, point a Postgres client at it, and write SQL.
Prerequisites
dodilCLI installed anddodil auth logindone — CLI Basics- A bucket — we’ll use
kb-prod:dodil data bucket create kb-prod -d "Tables quickstart" - A credential — an API key, a service account, or the bearer JWT from
dodil auth login. All three work on every wire; see Connect & wire adapters.
0. Connect
The bucket is the database. Connect once and reuse the session for the whole quickstart.
psql
# sslmode=require — the pg door enters on a raw host port (5432) but does
# terminate TLS. Never `prefer`: it falls back to plaintext without telling you,
# and your password here is your API-key/service-account secret.
# dbname is the bare bucket name.
psql "host=pg.uk-lon-1.dodil.io port=5432 dbname=kb-prod \
user=dk_XXXX password=$DODIL_SECRET sslmode=require"1. Create a table
A small events table with a composite primary key, a partition column, and one JSON column for arbitrary payload:
SQL
CREATE TABLE events (
id BIGINT NOT NULL,
user_id VARCHAR NOT NULL,
occurred_at TIMESTAMP NOT NULL,
event_type VARCHAR NOT NULL,
payload JSON,
PRIMARY KEY (id, user_id)
)
PARTITIONED BY (event_type);What that creates:
| Column | Declared as | Stored as | Notes |
|---|---|---|---|
id | BIGINT | long | Primary key (composite, with user_id) |
user_id | VARCHAR | string | Primary key (composite) |
occurred_at | TIMESTAMP | timestamp | Microseconds since epoch, UTC |
event_type | VARCHAR | string | Partition column — rows physically grouped by this |
payload | JSON | json | Arbitrary JSON — queryable with DuckDB JSON operators |
INTEGERis stored as 64-bit. A column declaredINTEGER/INTresolves tolong, not a 32-bit type — surrogate keys are 64-bit snowflake ids, so a narrower foreign key would silently truncate. Introspection therefore reportsBIGINTfor a column you declaredINTEGER. The full fifteen-type vocabulary — includingDECIMAL(p,s),T[],STRUCT(…)andVECTOR(N)— is in SQL Compatibility → Column types.
Check the schema landed. DESCRIBE is the verb; the CLI’s describe (and its alias get) just runs it for you:
SQL
SHOW TABLES;
DESCRIBE events;column type nullable pk default
id long false true
user_id string false true
occurred_at timestamp false false
event_type string false false
payload json true false2. Insert rows
SQL
INSERT INTO events (id, user_id, occurred_at, event_type, payload) VALUES
(1, 'u-101', TIMESTAMP '2026-05-27 10:00:00', 'click', '{"page":"/pricing"}'),
(2, 'u-101', TIMESTAMP '2026-05-27 10:01:00', 'click', '{"page":"/signup"}'),
(3, 'u-102', TIMESTAMP '2026-05-27 10:02:00', 'purchase', '{"sku":"A-12","amount":49.99}');The response carries the count of rows the statement wrote — WAL appends and tombstones. Because events has a primary key, those rows land in the write-ahead log first and the compactor folds them into Delta on the next drain. A table with no declared primary key has no WAL at all; its writes append straight to Delta.
3. Query
SQL
SELECT event_type, COUNT(*) AS n
FROM events
GROUP BY event_type
ORDER BY n DESC;You will see the rows you just inserted. Reads are read-your-writes by default — there is no freshness flag to set and no “strong mode” to opt into. Before every read the coordinator asks the writer whether the WAL backlog is drained; if that can be proven, the read takes the cheaper analytical path (a pure Delta scan) because at that point eventual and strong are the same answer. If it cannot be proven, the read merges the WAL overlay with Delta. Either way you see your write — once it has committed. The one exception is an open pg-wire transaction: a SELECT inside it does not see rows the same transaction has staged but not yet committed; use RETURNING on the write, or read after COMMIT. See SQL Compatibility → Transactions.
A strong read whose WAL overlay exceeds its cap aborts with
FAILED_PRECONDITIONrather than returning a partial answer — retry once compaction catches up. Details in Core Concepts → Freshness is not a client knob.
If a result is large, the plane spools it to object storage and returns a manifest of presigned parts instead of inline rows. The CLI tells you so and suggests a LIMIT; see Execute → Spooled results.
4. Query a JSON column
JSON columns are first-class — DuckDB’s native JSON operators work directly:
SELECT user_id,
payload->>'sku' AS sku,
(payload->>'amount')::DOUBLE AS amount
FROM events
WHERE event_type = 'purchase';->> extracts a JSON field as text; cast to a typed value as needed. ->, json_extract, json_each and the rest of the DuckDB JSON surface work too — see SQL Compatibility → JSON columns.
5. Update and upsert
SQL
-- Update by primary key.
UPDATE events SET event_type = 'click_pricing'
WHERE id = 1 AND user_id = 'u-101';
-- Upsert a batch.
MERGE INTO events AS t
USING (VALUES
(1, 'u-101', TIMESTAMP '2026-05-27 10:00:00', 'click_pricing', '{"page":"/pricing","variant":"B"}'),
(4, 'u-103', TIMESTAMP '2026-05-27 10:03:00', 'signup', '{"plan":"pro"}')
) AS s(id, user_id, occurred_at, event_type, payload)
ON t.id = s.id AND t.user_id = s.user_id
WHEN MATCHED THEN UPDATE SET event_type = s.event_type, payload = s.payload
WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.user_id, s.occurred_at, s.event_type, s.payload);
UPDATEandDELETEare refused without aWHEREclause. That is deliberate — useTRUNCATE TABLE eventsto clear a table.
A predicate that doesn’t mention the primary key is fine. The planner synthesises SELECT pk FROM events WHERE <your predicate> and routes the write through the WAL like any other keyed write, so there is no bypass to design around. See Core Concepts → Write routing.
6. Maintenance (optional)
# Force the WAL to drain into Delta now (it also drains on its own).
dodil data table compact events -b kb-prod
# Bin-pack small Delta files into larger ones — faster analytical scans.
dodil data table optimize events -b kb-prodYou usually don’t need these — the drain runs in the background. Reach for them after large bulk writes or nightly batches. compact → optimize is the canonical post-batch sequence.
What you just did
| Step | Statement | Where it ran |
|---|---|---|
| 1 | CREATE TABLE | Execute → DDL plan → Delta |
| 1 | SHOW TABLES / DESCRIBE | Execute → catalog read |
| 2 | INSERT | keyed_insert_bulk → WAL → drain MERGE |
| 3 | SELECT … GROUP BY | frontier check → Delta scan or overlay ∪ Delta |
| 4 | SELECT with JSON ops | same, DuckDB-native JSON |
| 5 | UPDATE / MERGE | keyed_update / merge_rows |
| 6 | Compact + OptimizeTable | Delta-side maintenance RPCs |
Cleanup
dodil data table delete events -b kb-prod
dodil data bucket delete kb-prod # only if you created it for this quickstartNext steps
- Core Concepts — the two-tier model, the frontier check, write routing, watermarks
- SQL Compatibility — DuckDB dialect, the fifteen types, statement shapes, the honest refusal list
- API Reference → Execute — every statement shape with examples
- Recipes → Manual table — this flow, end to end, with maintenance
- Recipes → Time travel + Restore — Delta Lake’s signature feature, and what restore destroys