Benchmarking LabA field guide to performanceSource ↗
Chapter 02Working labAbout 25 minutes

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.

Your outcome

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.

The experiment at a glance Same input. Two questions. Checked answers.
Shared input One orders table

2,000 or 20,000 identical rows in each engine.

Ask both questions
Find one orderOne ID → one row
Total the revenueAll orders → 48 groups
Run each in both DuckDB-Wasm and PGlite Postgres in the browser

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.

Inside the shared fixture One row = one order
Five example rows from the generated table. The marked rows share month 1 and region 1.
order_id month_id region_id amount_cents
2 1 2 174
3 1 3 211
4 1 4 248
Job 01 · Find

The order screen

Find an exact ID and return its four fields. The answer is one row.

Job 02 · Summarise

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.

These are synthetic order amounts and numbered months, not a customer workload. Both sizes contain every month/region combination.

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.

01 / Schema SQL · define the four integer columns
CREATE TABLE orders (
  order_id INTEGER,
  month_id INTEGER,
  region_id INTEGER,
  amount_cents INTEGER
);
02 / Fixture recipe JavaScript · repeat for offsets 0 through count − 1
const id = offset + 1;
const row = [
  id,
  Math.floor(offset / 4) % 12 + 1,
  offset % 4 + 1,
  id * 37 % 10000 + 100
];
03 / Load and prepare SQL · the same sequence in each engine
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?

Your experiment bench Real engines · runs in this tab

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.

Job 01 · Order screenReturns 1 row

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;
Job 02 · Revenue dashboardReturns 48 rows

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.

What this run compares

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.

The measurement boundaryOne warm SQL request, end to end

Before the measured requests Start engines → load rows → ANALYZE → verify → warm up

Clock startsClock stops
  1. PageSend SQL
  2. Worker + engineExecute query
  3. Worker wrapperConvert rows
  4. 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.

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.

  1. Name the conditions. Include the query, fixture size, browser builds and warm request boundary.
  2. Point to the evidence. Describe the observed result and its spread. Keep query time separate from startup and loading.
  3. 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.