Skip to content
Louvre Ops DB

Database rigour

What do the indexes buy?

Besides the indexes SQLite builds for keys, the refined schema declares seven: six for speed and one that enforces a rule (one first entry per ticket). Here they are measured: the same query on two copies of the demo database, with and without them, in repeated, interleaved rounds. The answer is a median with an interval, and the query plan shows why.

Reference run

Measured on the build machine

30 rounds per query after 3 warm-up runs; each sample repeats the query until it lasts at least 4 ms. Within each round the two copies run in a seeded random order (seed 20003). Intervals are 95% percentile-bootstrap intervals (2000 resamples). SQLite 3.49.1 through sql.js 1.14.2, Node v26.10.0, Apple M4 (darwin arm64), 2026-10-07.

Reproduce it

Run the same experiment in your browser

The browser runs the same engine on the same file, in a worker, on two in-memory copies. Nothing is sent anywhere and the committed database is never changed.

15 rounds per query, seed 20003; takes about 10 to 30 seconds and stays in this tab.

The queries

What was measured, and the plans

  1. Wing scans of one ticket

    Where did ticket 4321 go inside the museum? A point lookup on the largest table (19,545 rows): the index on wing_scan(ticket_id) turns a full scan into a search.

    SELECT wing_id, scanned_at FROM wing_scan WHERE ticket_id = 4321 ORDER BY scanned_at

    Plan without indexes

    SCAN wing_scan
    USE TEMP B-TREE FOR ORDER BY

    Plan with indexes

    SEARCH wing_scan USING INDEX wing_scan_ticket (ticket_id=?)
    USE TEMP B-TREE FOR ORDER BY

    1 row; both copies returned the same rows: yes. Each sample ran the query 6 times (without) and 214 times (with).

  2. Tickets in one order

    Which tickets did order 2500 buy? The index on ticket(order_id) serves the most common join in the schema.

    SELECT ticket_id, barcode FROM ticket WHERE order_id = 2500

    Plan without indexes

    SCAN ticket

    Plan with indexes

    SEARCH ticket USING INDEX ticket_order (order_id=?)

    2 rows; both copies returned the same rows: yes. Each sample ran the query 19 times (without) and 425 times (with).

  3. Audio-guide hires on one day, as a range

    Which guides went out on 6 August 2019? Written as a range on the raw column, the index on audio_guide_hire(issued_at) can be used.

    SELECT serial_number, issued_at FROM audio_guide_hire WHERE issued_at >= '2019-08-06' AND issued_at < '2019-08-07'

    Plan without indexes

    SCAN audio_guide_hire

    Plan with indexes

    SEARCH audio_guide_hire USING INDEX audio_guide_hire_issued (issued_at>? AND issued_at<?)

    5 rows; both copies returned the same rows: yes. Each sample ran the query 64 times (without) and 409 times (with).

  4. The same day, written with date()

    The same question as business question 4 writes it. Wrapping the column in date() hides it from the index, so both copies scan the whole table: the index cannot help a query written this way.

    SELECT serial_number, issued_at FROM audio_guide_hire WHERE date(issued_at) = '2019-08-06'

    Plan without indexes

    SCAN audio_guide_hire

    Plan with indexes

    SCAN audio_guide_hire USING COVERING INDEX audio_guide_hire_device

    5 rows; both copies returned the same rows: yes. Each sample ran the query 26 times (without) and 28 times (with).

  5. History of one audio-guide device

    When was one device hired, in order? The composite index on (serial_number, issued_at) answers the filter and the sort together; it also backs the trigger that stops double hires.

    SELECT hire_id, issued_at, returned_at FROM audio_guide_hire WHERE serial_number = (SELECT min(serial_number) FROM audio_guide_device) ORDER BY issued_at

    Plan without indexes

    SCAN audio_guide_hire
    SCALAR SUBQUERY 1
      SEARCH audio_guide_device USING COVERING INDEX sqlite_autoindex_audio_guide_device_1
    USE TEMP B-TREE FOR ORDER BY

    Plan with indexes

    SEARCH audio_guide_hire USING INDEX audio_guide_hire_device (serial_number=?)
    SCALAR SUBQUERY 1
      SEARCH audio_guide_device USING COVERING INDEX sqlite_autoindex_audio_guide_device_1

    9 rows; both copies returned the same rows: yes. Each sample ran the query 65 times (without) and 275 times (with).

  6. Entries on one day (proposed index)

    How many entrance scans were there on 6 August 2019? Only the proposed index on entry_scan(scanned_at) can help here; it is not in the shipped schema.

    SELECT count(*) FROM entry_scan WHERE scanned_at >= '2019-08-06' AND scanned_at < '2019-08-07'

    Plan without indexes

    SCAN entry_scan

    Plan with indexes

    SEARCH entry_scan USING COVERING INDEX entry_scan_scanned_at (scanned_at>? AND scanned_at<?)

    1 row; both copies returned the same rows: yes. Each sample ran the query 12 times (without) and 429 times (with).

  7. Most common wing routes (business question 5)

    In what order do visitors tour the wings? A whole-table aggregation reads every row either way; an index should make little or no difference.

    WITH scans AS (
      SELECT ticket_id, scanned_at, scan_id, wing_id,
             lag(wing_id) OVER (PARTITION BY ticket_id ORDER BY scanned_at, scan_id) AS previous_wing
      FROM wing_scan
    )
    SELECT count(*) FROM scans WHERE previous_wing IS NULL OR previous_wing <> wing_id

    Plan without indexes

    CO-ROUTINE scans
      CO-ROUTINE (subquery-3)
        SCAN wing_scan
        USE TEMP B-TREE FOR ORDER BY
      SCAN (subquery-3)
    SCAN scans

    Plan with indexes

    CO-ROUTINE scans
      CO-ROUTINE (subquery-3)
        SCAN wing_scan USING INDEX wing_scan_ticket
        USE TEMP B-TREE FOR LAST 2 TERMS OF ORDER BY
      SCAN (subquery-3)
    SCAN scans

    1 row; both copies returned the same rows: yes. Each sample ran the query 1 time (without) and 1 time (with).

Indexes dropped for the “without” copy: audio_guide_hire_device, audio_guide_hire_issued, audio_guide_hire_ticket, entry_scan_one_first_entry, exhibition_booking_ticket, ticket_order, wing_scan_ticket, entry_scan_scanned_at. SQLite's automatic indexes (behind primary keys and UNIQUE constraints) cannot be dropped and stay in both.