DuckDB or Postgres?
We’ll now compare two databases using a small set of sales orders. You’ll ask each to find one order and total the revenue, then check the answers and measure the time. These two everyday jobs show why a useful comparison starts with what the customer needs to do.
Run two real databases in this browser, check their answers, separate preparation from request time, and explain what your measurements support. No database experience is required; the chapter introduces the requests you will run.
01 / The customer decision
Two jobs can lead to different answers
In Chapter 1, you compared two ways to add numbers. Now we’ll use the same method to compare two database engines: programs that store data and answer requests about it. A request to retrieve or calculate information is called a query.
Imagine a sales operations team choosing a database for an order screen and a revenue dashboard. The screen needs one known order; the dashboard needs totals by month and region. We’ll use these jobs because they ask the engines to do different kinds of work, while keeping the example small enough to inspect.
Which engine completes each job sooner on your device? You’ll run both queries on the same orders, check the returned values, and compare the times. This gives you a first finding to investigate in later chapters, where we’ll examine configuration, customer usage and the reliability of the measurements.
DuckDB is often used for analytical work: reading and summarising sets of data. Postgres is a general-purpose relational database used for many applications. Those descriptions suggest questions to investigate; they do not supply a result for this experiment.
Here you will run DuckDB-Wasm and PGlite, a WebAssembly build of Postgres. The introduction explains why the labs use WebAssembly. Both engines use SQL, the language used to ask a database for rows. Everything runs locally in this tab; a customer’s server deployment needs its own measurements.
2,000 or 20,000 identical rows in each engine.
Match every answer to an independent reference, then compare the request times.
A map of the method, not a measured result. A fixture is the fixed input shared by the tests.
02 / Worked example
Follow an order through the comparison
A table stores rows of records and named columns. Our table has one row per order. A schema defines its column names and types. Every value here is an integer, including revenue in cents, so decimal rounding and time zones cannot change the answer.
| order_ |
month_ |
region_ |
amount_ |
|---|---|---|---|
| 1 | 1 | 1 | 137 |
| 2 | 1 | 2 | 174 |
| 3 | 1 | 3 | 211 |
| 4 | 1 | 4 | 248 |
| 49 | 1 | 1 | 1,913 |
The order screen
Find an exact ID and return its four fields. The answer is one row.
The revenue dashboard
Rows 1 and 49 contribute 137 + 1,913 cents to the same group. Add every order into 12 months × 4 regions: 48 rows.
An index is an additional structure that can help locate rows without examining the whole table. This first baseline creates no indexes or constraints in either engine. The lookup therefore asks how these configurations find one order; it is not a test of a tuned, indexed application. Chapter 3 will help you decide what equivalent work requires.
See the complete schema and deterministic data recipe
IDs start at 1. The recipe cycles through four regions and twelve months; amounts repeat a fixed integer formula. No random seed, external data, or clock is involved.
CREATE TABLE orders (
order_id INTEGER,
month_id INTEGER,
region_id INTEGER,
amount_cents INTEGER
);
const id = offset + 1;
const row = [
id,
Math.floor(offset / 4) % 12 + 1,
offset % 4 + 1,
id * 37 % 10000 + 100
];
BEGIN;
-- CREATE TABLE above, then INSERT INTO orders VALUES (...), (...);
-- One INSERT statement per 500 generated rows, in ID order.
COMMIT;
-- Timed separately after loading:
ANALYZE orders;
ANALYZE collects table statistics that help an engine
plan a query. We time it separately. Identical SQL does not
guarantee identical execution plans or memory use.
Inspect every INSERT statement for the selected size
Enable JavaScript to expand the deterministic recipe into all INSERT statements.
03 / Your experiment
Inspect the requests. Run the comparison.
Start with 2,000 orders. Read the two requests below and, if you like, write a prediction. Then run again with 20,000 orders. Does the relationship between the engines change when there is more work?
Each pair runs one request on each engine. The first engine alternates between pairs. Each run measures both queries; the SQL below is a source view of these presets.
Read the two SQL requests
These are the exact requests run by both engines. Read each as a description of the work the customer needs.
Find the midpoint order
Changing the fixture size chooses the midpoint order ID.
SELECT names the fields, FROM names
the table, and WHERE restricts the rows.
SELECT order_id, month_id, region_id, amount_cents
FROM orders WHERE order_id = 1000;
Total every month and region
SUM adds the cents; GROUP BY forms
the month/region groups. ORDER BY fixes their
order so the answers can be compared exactly.
SELECT month_id, region_id,
CAST(SUM(amount_cents) AS BIGINT) AS revenue_cents
FROM orders
GROUP BY month_id, region_id
ORDER BY month_id, region_id;
BIGINT is a 64-bit integer. The total stays exact
for these fixtures.
Prediction and observations (optional, saved on this device)
Engines download only when you press Run: about 54 MiB of Wasm/data plus JavaScript, cached according to your browser. Both use one thread and fresh in-memory databases. Allow roughly 300 MiB or more of free memory; mobile devices may need the smaller size. Closing the tab or pressing Stop discards engine memory.
Warm browser requests · one client · one thread per engine · in-memory tables · no indexes
Ready. No database engine has been loaded. Your browser will perform every measurement.
Limits: 60 seconds for each engine’s startup, 30 seconds for each load, and 15 seconds per setup/query request. Keep this tab visible: switching away stops the run to avoid accepting background measurements. A time limit or failed answer rejects the run and terminates its workers.
How answers are checked, before trusting any timing
A separate JavaScript reference finds the midpoint row and accumulates all cents by month and region using a map. It does not ask either engine for the expected answer. Both queries are verified before warm-up, during one unmeasured warm-up request, and after every measured request. Every field and row must match, including the 48 groups’ order.
Expected values for the current controls, represented as decimal strings to compare integer types consistently:
Enable JavaScript to inspect all reference rows.
Full reference source and actual output rows are included in the JSON evidence. An incorrect answer produces an error and no new completed record.
04 / Read your result
First check what the clock included
The useful number depends on where you start and stop the clock. Here, each sample measures one complete browser request: from sending the SQL to receiving all returned fields as plain integer strings.
Before the measured requests Start engines → load rows → ANALYZE → verify → warm up
- PageSend SQL
- Worker + engineExecute query
- Worker wrapperConvert rows
- PageReceive strings
After the clock stops Check every returned field against the independent reference
Method illustration, not a time scale. Scheduling, worker messages and result transfer are inside the clock; setup is reported separately.
DuckDB returns an Arrow table, a column-oriented result format; PGlite returns JavaScript rows. Their wrappers take different paths to produce the same plain result. Wrapper work stays inside this common boundary and can dominate tiny requests, so these timings describe the whole browser request rather than engine execution alone.
Your measured requests
The controls have changed. These results and their export still describe the selected completed run. Run again to measure the new settings.
Warm request time · milliseconds. Lower is faster. Both engines share a zero-based scale within each plot; the two queries can have different scales.
- One request
- Median
- Observed range
Dots are separated vertically to keep repeated values visible. Their horizontal position shows the time.
Exact values: medians, observed ranges and request counts
The detailed timing tables below scroll sideways on narrow screens.
| Query | Engine | Median ms | Observed range ms | Requests |
|---|
Every returned row matched the reference. These few warm samples describe this run’s spread; they do not establish statistical confidence or a universal winner. Compare the raw requests before treating a small gap as meaningful.
Startup, loading and preparation times
Startup includes lazy imports, download/cache access, Wasm
compilation, database creation, and reading the runtime version.
Loading includes table creation and all inserts in one
transaction. The separate analyze phase gathers
planner statistics. Verification and warm-up rows are sums of
two request times, one per query; they are not measured query
samples.
| Engine | Phase | Elapsed ms |
|---|
Every measured request and its order
| Query | Pair | Position | Engine | Elapsed ms | Answer check |
|---|
Engine package and runtime versions
Package versions identify the shipped JavaScript wrappers.
Runtime versions come from SELECT version() inside
each running database. Both belong in a reproducible claim.
JSON includes exact SQL, all INSERT statements, fixture/reference source, asset hashes, configuration, engine versions, browser details, setup phases, every checked sample, and your current interpretation. Earlier completed runs stay available here until you leave the tab. Download both sizes before leaving.
Before reading the table as a recommendation: how would the decision change if the order screen needs an index, or the dashboard runs only once after each data load? Query time alone cannot answer either question.
Hint 1 · What should I observe first?
Start with correctness. Then read the lookup and revenue rows separately. Say which median was lower in this run, how large the sample ranges were, and whether a tiny difference could be request overhead.
Hint 2 · What changes with the larger fixture?
Run the second size and retain both records. Compare the change for each engine and each query. If a warm query is fast but loading is expensive, consider how often the customer pays each cost.
Hint 3 · How cautious should the claim be?
Use “In this browser run, with [size], [query] and [configuration]…” Then describe the observation. A next experiment should test one condition that matters for the customer, such as indexing, more realistic data, or the intended server deployment.
05 / Keep the comparison honest
The same data is only the beginning
Held constant
Generated rows, SQL, output order, one thread and the request timing boundary. Answers are verified; the first engine alternates in measured pairs.
Still different
Storage layouts, optimisers, defaults and wrappers. Both engines stay in memory. Setup runs DuckDB first; warm-up, caches and a busy device can affect later requests. Alternating order distributes some effects without removing them.
Not yet tested
Network round trips, persistent disk, concurrent users, realistic customer data or native server deployments. The tiny browser baseline gives you a starting observation and a reason to choose the next experiment.
Later chapters turn these limits into design choices: equivalent work, representative workloads, trustworthy measurement, uncertainty and demand. For now, learning to name a missing condition is part of doing the benchmark well.
06 / Independent practice
Write a claim someone else can check
Complete two runs, one at each size. Write an initial finding for the sales operations team that a colleague could reproduce. Use your measurements to make a conditional claim.
- Name the conditions. Include the query, fixture size, browser builds and warm request boundary.
- Point to the evidence. Describe the observed result and its spread. Keep query time separate from startup and loading.
- Choose the next check. Name one condition that could change the customer’s decision, and explain why.
Saved as a draft on this device when storage is available; included with the selected run at export time. A measured record always keeps its original SQL, configuration and samples.
Check your work
Could a colleague recreate your input and timing boundary from the JSON? Have you named one query rather than declaring an overall winner? Have you distinguished this browser baseline from an indexed server deployment? Does your next check address the customer’s decision?
07 / Sources and method
Keep the experiment open to inspection
The engine code, worker scripts, data bundle and WebAssembly binaries are served by this site. No CDN or database backend is used. Fresh workers isolate each run; Stop, time limits and page exit terminate them. Each successful run closes both engines before releasing its workers.
-
DuckDB-Wasm documentation
· npm package
@duckdb/duckdb-wasm 1.32.0 -
PGlite documentation · npm
package
@electric-sql/pglite 0.5.13 - Detailed measurement contract and reproduction
- Engine source and licence notices