Can DuckDB be your SaaS product's warehouse? Where the ceiling actually is
By Arshad Ansari
A recurring question, roughly: we're a small SaaS, we need analytics — internal dashboards and eventually a customer-facing one. Can DuckDB be the warehouse, or do we need Snowflake/BigQuery from day one?
Almost always: yes, it can, for longer than you'd think. But "small SaaS" hides three very different workloads, and only two of them fit comfortably. Here's how to tell which you have.
The three workloads hiding behind "analytics"
1. Internal analytics. You and a few colleagues asking questions about signups, churn, revenue. Handful of people, no concurrency to speak of, latency measured in "before my coffee gets cold."
DuckDB fits this with enormous room to spare. This is the easiest yes in data engineering. A nightly job writes Parquet, DuckDB reads it, your BI tool or notebook queries it. Done.
2. Embedded customer-facing analytics. A dashboard inside your product — each customer sees their own numbers. This is where it gets interesting, and where the answer becomes "yes, with a specific design."
3. A shared analytics platform for a data team. Many analysts, ad-hoc SQL, governance, roles. This is a warehouse workload. If you genuinely have this, buy a warehouse. Most companies asking the question do not have this yet, and some never will.
The mistake is buying for (3) while actually living in (1).
Where the ceiling is, concretely
The instinct is to think about total data volume. That's the wrong number. Three things bound a single-node design, roughly in the order you'll hit them:
Concurrent write throughput. DuckDB is single-writer. One process writes at a time. For a SaaS this is usually fine because analytics writes are batch — a job runs, produces Parquet, exits. It becomes a problem the moment you want near-real-time ingestion from multiple services writing continuously. That's the first real ceiling, and it arrives independent of data size.
Concurrent query volume. A single node serving customer-facing dashboards can handle a genuinely surprising number of requests when queries are small and well-partitioned — but it's one machine. When p99 latency starts drifting under load and you're already caching, you've found the second ceiling.
Working-set size. Last, not first. Modern servers take a lot of RAM, and Parquet with good partitioning means a query touches a fraction of the data. Teams hit the concurrency ceilings long before the size ceiling.
Notice none of these is "how many GB do you have". Which is exactly why "we have 500GB, we need Snowflake" is a non-sequitur.
The design for customer-facing analytics
The pattern that makes (2) work is partition by tenant, precompute the aggregates:
s3://analytics/events/
tenant_id=acme/dt=2026-08-12/part-0.parquet
tenant_id=acme/dt=2026-08-13/part-0.parquet
tenant_id=globex/dt=2026-08-13/part-0.parquet
A customer's dashboard query then reads only that customer's partitions:
import duckdb
con = duckdb.connect(read_only=True) # in-memory, read-only, safe to fan out
con.execute("INSTALL httpfs; LOAD httpfs;")
def daily_active(tenant_id: str, since: str):
# Parameterised — tenant_id is NEVER string-formatted into the path or SQL.
return con.execute(
"""
SELECT date_trunc('day', event_at) AS day,
count(DISTINCT user_id) AS dau
FROM read_parquet(
's3://analytics/events/tenant_id=' || ? || '/dt=*/**/*.parquet',
hive_partitioning = true)
WHERE event_at >= ?
GROUP BY 1 ORDER BY 1
""",
[tenant_id, since],
).fetchall()
Two things are doing the work. Hive partitioning prunes to one tenant's directory before reading a byte — so query cost tracks that customer's data, not your whole corpus. And a read-only connection means you can run this in as many worker processes as you have cores, with no lock contention.
The security note matters more than the performance one. tenant_id decides which customer's data is read. It must come from your authenticated session, never from a request parameter, and it must be parameterised rather than interpolated — a path built by string concatenation from user input is a cross-tenant data leak, and it will not look like a SQL injection when it happens. Validate it against the session's tenant before it reaches this function.
For anything a customer sees repeatedly, precompute. Roll up to daily aggregates in the batch job and let the dashboard read the rollup. A customer-facing chart querying raw events on every page load is a design that gets expensive on any engine.
What you actually give up
Being straight about the trade:
- No governance layer. No roles, no row-level security. Your tenant isolation is your code — the partition path and the session check above. A warehouse would give you RLS as a database feature. Here it's your responsibility, and it's the single highest-stakes piece of the design.
- No vendor to escalate to. When it's slow at 2am, it's yours.
- Real-time is awkward. Batch is natural; streaming ingestion is not what this is for.
- You own the operational story. Backups, versioning, retention — all yours, and all straightforward if Parquet on object storage is the source of truth, because then they're S3 problems with well-known answers.
What you get: no per-query meter, dev/CI/production running the same engine, and a bill that's mostly S3 storage. For a pre-Series-A company that's often the difference between analytics being a line item and analytics being a rounding error.
The migration path, if you outgrow it
The reason I recommend this for early SaaS isn't that it scales forever. It's that the exit is cheap if you do it right.
Keep Parquet on object storage as the source of truth. Then DuckDB is just the query engine reading it — and every serious warehouse (Snowflake, BigQuery, ClickHouse, Databricks) can read Parquet from object storage too. Outgrowing DuckDB means pointing a different engine at data that's already in an open format, in place.
Compare that with outgrowing a proprietary warehouse, where the data is inside the vendor and the exit is an export project.
This is the actual argument for local-first in a startup: not that it's cheaper today, though it is, but that it doesn't compound a decision you can't reverse. The expensive mistake isn't picking the small tool — it's picking a tool whose data you can't get back out.
So, should you?
If you're a small SaaS with internal analytics and an embedded per-tenant dashboard, and your ingestion is batch: yes, and you'll likely be fine for years.
If you have a data team writing ad-hoc SQL all day, or hard multi-tenant compliance requirements, or genuinely streaming ingestion from many writers: buy the warehouse, and don't feel bad about it.
If you're unsure which you are, the diagnostic is one question: how many processes need to write at the same time? One, and the design above works. Many, and you need a server.
The long version, with runnable code, is my book Local-First Analytics. Related: is DuckDB safe for production, DuckDB vs Snowflake, do you actually need a data warehouse, and when local-first runs out of road.
If you'd rather have someone size this against your actual numbers before you commit either way, a Data Platform Audit is a week and a written roadmap you keep. The scoping call below is free.
Get new posts by email
Data engineering notes like this one — pipelines, warehouse cost, and what actually breaks in production. A few a month, never padded to hit a schedule.
No sequence, no pitch deck. Reply 'stop' once and you're off — it reaches me, not a queue.
Want the whole playbook?
This post is one slice of a bigger method. Local-First Analytics — 314 pages, runnable code for every chapter — is the full build: DuckDB, Parquet and Arrow, from install to production. On Amazon, or request a free review copy.
Get the bookNot ready to buy? Read chapter 1 free — the whole chapter, no email required.
Rather talk it through? Book a free 30-minute call.