agentleFS
Evan-Kim2028Evan-Kim2028/agent-skills/skills/data/duckdb/SKILL.md

data-duckdb

Skill1 starsChanged 3 months agoMITView on GitHub

Use when building or debugging pipelines that use DuckDB as the embedded analytical engine — tuning memory_limit and threads, handling larger-than-memory queries and disk spilling, reading Parquet/CSV efficiently (predicate & projection pushdown, glob/Hive partitioning, union_by_name), writing Parquet well (PER_THREAD_OUTPUT, ROW_GROUP_SIZE, the partitioned-write temp-blowup trap), connection reuse, and EXPLAIN ANALYZE profiling. Covers DuckDB-over-Parquet and DuckDB-as-query-layer on a single node. Don't use as an OLTP/transactional application database, for cluster-scale work that genuinely exceeds one big node, or for generic SQL tutoring. For querying Iceberg tables (`iceberg_scan`, catalog reads) see data-apache-lakehouse; for serving DuckDB results over HTTP see data-api. Prefer the data hub when the right data skill is unclear or the task spans ingest→store→serve.

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Test: run EXPLAIN — are the WHERE predicate and column list pushed into the scan, or is there a full scan with a late filter?

    line 44

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

DuckDB — embedded analytical engine

DuckDB as the compute layer of a single-node data pipeline: vectorized, columnar, in-process, reading and writing Parquet directly with filter and projection pushdown. This skill is the engine discipline — memory, threads, spilling, file layout, and connection lifecycle. It is the format-agnostic complement to data-apache-lakehouse (Iceberg specifics) and data-api (serving), and inherits the cross-cutting principles from the data hub.

Verified: 2026-06 against DuckDB 1.5.x. Numbers below (memory-per-thread, thread multipliers, ROW_GROUP_SIZE defaults) are guidance from the DuckDB performance docs and are version-sensitive — re-check on a major bump. v1.5.3 (May 2026) statically links jemalloc on Linux, which materially improves heavy out-of-core spilling; older builds spill more slowly. Code & config: references/duckdb-operations.md.

When to invoke this skill

  • Choosing DuckDB (vs Polars/DataFusion/a warehouse) for a transform or serving query, and sizing it.
  • A query OOMs, spills heavily, or runs far slower than the data volume warrants.
  • Reading large Parquet datasets (single file, glob, or Hive-partitioned) and you want pruning to actually happen.
  • Writing Parquet output and deciding file/row-group layout, or a partitioned write is blowing up disk.
  • A long-lived service or pipeline opening DuckDB connections — deciding lifecycle and tuning.

Principles

Load-bearing for any DuckDB workload. Each is paired with a falsifiable test — if you can't pass it, the principle is being violated.

DuckDB is a single-node engine — confirm it fits before reaching for it

DuckDB rivals a cluster when the data fits and the job is bounded: the working set (the largest hash table, sort, or join state) fits in memory_limit, or spills to fast local disk. It is not a distributed warehouse, and it is not an OLTP store. The honest sizing question is about the largest intermediate, not the input size — a 500 GB scan that aggregates to 10 K groups is trivial; a 50 GB join that materializes a 200 GB hash table is not.

Test: name the largest blocking intermediate this query builds (join hash table / sort / group state) and whether it fits memory_limit or spills to NVMe. If the answer is "the whole input," or "I don't know," size it before running.

Set memory_limit and threads explicitly; budget per thread

Defaults (memory_limit = 80% RAM, threads = cores) are a starting point, not a config. Budget 1–4 GB per thread — ~1–2 GB/thread for aggregation-heavy, 3–4 GB/thread for join-heavy, never below ~125 MB/thread. So threads and memory_limit are chosen together: 8 threads of join work wants ~32 GB. For remote/object-store reads, raise threads to 2–5× cores — the bottleneck is synchronous HTTP latency, not CPU.

Test: is memory_limit / threads in the 1–4 GB band for this workload's shape? For S3/GCS reads, is threads above core count? If you're on bare defaults for a heavy job, you haven't tuned it.

Push work into SQL; let pushdown prune

Filter, project, and aggregate in the query so DuckDB prunes row groups (predicate pushdown) and skips columns (projection pushdown) at the Parquet scan. Never SELECT * into a DataFrame and filter in Python — that reads everything and discards it. Parameterize values; never string-interpolate user input.

Test: run EXPLAIN — are the WHERE predicate and column list pushed into the scan, or is there a full scan with a late filter? If the bytes read ≈ table size for a selective query, pushdown isn't happening.

Spilling is a safety net with holes — design within them

Larger-than-memory GROUP BY / JOIN / ORDER BY / windowed operators spill to temp_directory (put it on SSD/NVMe — HDD spilling is brutal). But the net has holes: list() and string_agg() cannot offload to disk, and a query with several blocking operators at once can still OOM even under the limit. Lower memory_limit below 80% to leave allocator headroom, and decompose a job that can't fit into bounded batches rather than hoping the spill saves you.

Test: does this query aggregate unbounded groups with list()/string_agg(), or stack multiple blocking operators? If yes, it can OOM despite spilling — batch it or restructure.

Reuse one connection — it is the cache

A DuckDB connection holds metadata, the buffer cache, and loaded extensions. Open one per worker (or a small pool) and reuse it; reconnecting per query throws away cached state and re-pays setup. In a service, open it once at startup, not per request.

Test: does the process open a connection per request/query, or once and reuse it? Per-request connections mean you're discarding the cache every call.

Parquet layout is a contract you write for the next reader

On write: PER_THREAD_OUTPUT (one file per thread) is the fast path when file count is flexible; size ROW_GROUP_SIZE so each file has at least as many row groups as reader threads (default 122,880 rows; min 2,048) — too few row groups starves intra-file parallelism, too many bloats metadata. Watch the partitioned-write trap: PARTITION_BY without bounded threading can make temp storage balloon until the disk fills; bound the threads or batch the write. On read: glob or Hive partitioning to span files, union_by_name to tolerate schema drift.

Test: does each output file have ≥ threads row groups? Is any PARTITION_BY write thread-bounded? If a partitioned write keeps growing temp files, that's the trap, not a slow disk.

Choose persistence deliberately

A persistent .duckdb file has compression on by default (often ~8× smaller, and faster to scan than uncompressed in-memory). Pure :memory: has compression off unless you ATTACH ':memory:' (COMPRESS). Don't default to in-memory for a working set that would benefit from compressed on-disk storage — and don't keep a persistent file you treat as disposable cache.

Test: is the choice between persistent and in-memory deliberate, given the working-set size and whether the data outlives the process? If it's :memory: by habit on a large set, reconsider.

Anti-patterns

SmellWhat's wrong
con.execute("SELECT * FROM read_parquet(...)").df() then filter in pandasNo pushdown — reads the whole dataset to discard most of it
New duckdb.connect() per request/queryThrows away buffer cache + metadata; re-loads extensions every call
Bare defaults on a 100 GB join jobmemory_limit/threads not budgeted per-thread → OOM or thrash
temp_directory on an HDD (or unset on a tiny /tmp)Spilling crawls or fills the volume mid-query
PARTITION_BY write with unbounded threadsTemp storage doubles until the disk is exhausted (the partitioned-write trap)
list()/string_agg() over unbounded groupsCannot spill — OOMs regardless of memory_limit
threads left at core count for S3/GCS readsUnder-parallelized; synchronous HTTP latency dominates
One giant Parquet file with one row groupNo intra-file parallelism; one thread does all the work
Reading remote data repeatedly per queryRe-fetches over the network; cache to a local persistent table or Parquet

References

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.