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. When that day comes, ClickHouse vs Snowflake is the choice most SaaS teams land on, and embedded analytics pushes it one way hard.

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.

Common questions

How much data can a single DuckDB node handle for a SaaS product?
Total volume is the wrong number to plan around. Three limits bind a single-node design, and you hit them in this order: concurrent write throughput, because DuckDB is single-writer and continuous ingestion from several services breaks it; concurrent query volume, because it is one machine serving your dashboards; and only last the working-set size, which modern RAM and good partitioning push a long way out. Teams reach the concurrency ceilings well before the size ceiling, which is why "we have 500GB, so we need Snowflake" does not follow.
Can DuckDB power a customer-facing analytics dashboard?
Yes, with a specific design: partition by tenant, precompute the aggregates. Writing to s3://analytics/events/tenant_id=acme/dt=2026-08-13/ and reading with hive_partitioning means a query prunes to one customer's directory before reading a byte, so cost tracks that customer's data rather than your whole corpus. Serve it from read-only connections, which take no write lock and fan out across as many worker processes as you have cores. Anything a customer loads repeatedly should read a daily rollup, not raw events.
How do I isolate tenants in a DuckDB-backed dashboard?
In your own code, and it is the highest-stakes part of the design. DuckDB has no row-level security, so the tenant_id that decides which partition is read must come from the authenticated session, never from a request parameter, and it must be passed as a query parameter rather than concatenated into the path or the SQL. A path built by string formatting from user input is a cross-tenant data leak, and it will not look like SQL injection when it happens. If you need isolation as a database feature rather than an application invariant, buy the warehouse.
How hard is it to migrate off DuckDB when we outgrow it?
Cheap, if you kept Parquet on object storage as the source of truth. Then DuckDB is only the query engine, and Snowflake, BigQuery, ClickHouse and Databricks can all read the same files in place — outgrowing it means pointing a different engine at data that is already in an open format. Compare that with outgrowing a proprietary warehouse, where the data lives inside the vendor and leaving is an export project. That reversibility, not the lower bill, is the real argument for starting here.
How do I embed DuckDB in a serverless app for sub-second dashboard refreshes?
Treat each function invocation as a short-lived DuckDB process that reads Parquet, not as a connection to a database. Keep per-tenant, pre-aggregated Parquet in object storage, sized so one dashboard reads a few small files; open DuckDB in memory inside the function, query those files and return the result. Sub-second then depends on three things: files already shaped for the charts; a warm function (bundle the httpfs extension in the image rather than installing it at runtime, or every cold start downloads it); and a cache in front for repeated views. Serverless suits DuckDB because the read path needs no shared connection. What it cannot do is write: concurrent invocations writing one file break the single-writer rule, so do writes in a separate scheduled job that publishes new Parquet. If "refresh" means data from the last few seconds rather than the last few minutes, that is a streaming workload, and ClickHouse is the better fit.

Does DuckDB fit your system?

The 16-question production-fit checklist I run before putting DuckDB on a critical path — writers, working set, durability, memory, and who talks to it. Each question comes with what a bad answer sounds like.

One email with the whole checklist. Nothing follows it. Reply and it reaches me, not a queue.

Want the whole playbook?

If this was useful, the long version is my book. 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 book

Not ready to buy? Read chapter 1 free — the whole chapter, no email required.

Rather talk it through? Book a free 30-minute call. No slot that suits your time zone? Email [email protected].