Skip to content
Louvre Ops DB

Business questions

Ten questions, answered

The brief listed questions the museum's database should be able to answer. Here each one runs against the refined schema and five years of synthetic activity (1 March 2015 to 29 February 2020), next to an honest verdict on whether the 2020 design could have answered it. Results are computed on the server from the bundled SQLite database.

Question 1

2020 design: partly

How many tickets were bought with each payment method in the current financial year?

2020: Tickets can be traced to a payment method (Ticket to Invoices to Payment Methods to EFTPOS or Cash), but no 2020 table records when a purchase was made, so the count cannot be limited to a financial year. 2026: Every order has an ordered_at timestamp and exactly one payment whose method is a single column.

Answer to question 1
Payment methodTickets
credit_card629
digital_wallet420
debit_card262
cash81
  • Assumes: Financial year runs 1 July to 30 June (the Australian convention, the subject's home; the brief does not define one). For a calendar year, set fy_start to '2020-01-01' and fy_end to '2020-12-31'.
  • Assumes: A ticket counts in the year it was ordered, whatever day it was used.
Tickets by payment method
  • credit_card629
  • digital_wallet420
  • debit_card262
  • cash81
Show the SQL
-- Tickets sold per payment method in the current financial year
WITH params AS (SELECT '2019-07-01' AS fy_start, '2020-06-30' AS fy_end)
SELECT p.method        AS payment_method,
       sum(l.quantity) AS tickets
FROM purchase_order o
JOIN payment p     ON p.payment_id = o.payment_id
JOIN order_line l  ON l.order_id = o.order_id
JOIN product pr    ON pr.product_id = l.product_id
CROSS JOIN params
WHERE pr.code IN ('TICKET_ONLINE', 'TICKET_ON_SITE')
  AND date(o.ordered_at) BETWEEN params.fy_start AND params.fy_end
GROUP BY p.method
ORDER BY tickets DESC;
Open in the playground

Question 2

2020 design: answerable

Over the last six months, which entrance had the most visitors arriving on foot?

2020: Answerable by joining Entrance to Ticket.Transportation. Two catches: a walker who comes back through a different gate is counted twice, and a re-entry through the same gate cannot be stored at all (Entrance is keyed by gate and ticket). 2026: Transport is recorded once, on the first entry scan, so re-entries never double-count.

Answer to question 2
EntrancePedestrian visitors
Pyramid144
Passage Richelieu48
Carrousel du Louvre36
99 rue de Rivoli27
Porte des Lions16
  • Assumes: 'On foot' means the visitor answered 'Walk' at their first entry of the day.
  • Assumes: "Last six months" ends on the dataset's last day, 2020-02-29.
Pedestrian visitors by entrance
  • Pyramid144
  • Passage Richelieu48
  • Carrousel du Louvre36
  • 99 rue de Rivoli27
  • Porte des Lions16
Show the SQL
-- Entrances ranked by visitors who walked to the museum, last six months
WITH params AS (SELECT '2020-02-29' AS today)
SELECT en.name  AS entrance,
       count(*) AS pedestrian_visitors
FROM museum_visit v
JOIN entrance en ON en.entrance_id = v.entrance_id
CROSS JOIN params
WHERE v.transport_mode = 'Walk'
  AND v.visit_date >  date(params.today, '-6 months')
  AND v.visit_date <= params.today
GROUP BY en.name
ORDER BY pedestrian_visitors DESC;
Open in the playground

Question 3

2020 design: answerable

What share of each day's visitors hire an audio guide?

2020: Answerable: daily visitors come from Entrance and hires from Hired Audio Guide, both via Ticket ID. 2026: The museum_visit view gives one row per admitted ticket per day.

Answer to question 3
MonthDaysVisitorsHired guideAvg daily pct
2019-03281911817
2019-04292531814.3
2019-0528217159.5
2019-06261542414.9
2019-07272083022.3
2019-0829200259.6
2019-09231041211.8
2019-10252061713.6
2019-11282172016.4
2019-12272342922.6
2020-01251241814.2
2020-02221071621.8
  • Assumes: A visitor is an admitted ticket. The share is worked out for every day, then averaged by month for the last 12 months.
Avg daily pct by month
  • 2019-0317%
  • 2019-0414.3%
  • 2019-059.5%
  • 2019-0614.9%
  • 2019-0722.3%
  • 2019-089.6%
  • 2019-0911.8%
  • 2019-1013.6%
  • 2019-1116.4%
  • 2019-1222.6%
  • 2020-0114.2%
  • 2020-0221.8%
Show the SQL
-- Share of each day's visitors who hired an audio guide, averaged by month
WITH daily AS (
  SELECT v.visit_date,
         count(DISTINCT v.ticket_id) AS visitors,
         count(DISTINCT h.ticket_id) AS hired_guide
  FROM museum_visit v
  LEFT JOIN audio_guide_hire h ON h.ticket_id = v.ticket_id
  GROUP BY v.visit_date
)
SELECT strftime('%Y-%m', visit_date)                     AS month,
       count(*)                                          AS days,
       sum(visitors)                                     AS visitors,
       sum(hired_guide)                                  AS hired_guide,
       round(avg(100.0 * hired_guide / visitors), 1)     AS avg_daily_pct
FROM daily
WHERE visit_date > date('2020-02-29', '-12 months')
GROUP BY month
ORDER BY month;
Open in the playground

Question 4

2020 design: answerable

On a given day, how long was each audio guide in use?

2020: Answerable from Hired Audio Guide's Issued Time and Return Time. The catch is upstream: a guide still in service has no Retired Date, so it cannot be registered, and its hires are refused with it. 2026: Return time must be after issue time, and a device cannot be out with two visitors at once.

Answer to question 4
Serial numberLanguageIssuedReturnedMinutes in use
D952G324M2778N96Korean09:30:5511:19:09108
D207S645U1857P52French12:09:0313:52:39104
D004U524E2632P53Italian13:21:5814:46:1284
D831V786T8186E00English14:41:4916:20:1498
D004U524E2632P53English15:11:1516:23:1272
  • Assumes: The example day is Tuesday 6 August 2019, in the summer peak; change params.day to look at another.
  • Assumes: Guides that were never returned show no duration.
Minutes in use by serial number
  • D952G324M2778N96108
  • D207S645U1857P52104
  • D004U524E2632P5384
  • D831V786T8186E0098
  • D004U524E2632P5372
Show the SQL
-- Minutes each audio guide was out on one day
WITH params AS (SELECT '2019-08-06' AS day)
SELECT h.serial_number,
       lang.name                       AS language,
       time(h.issued_at)               AS issued,
       time(h.returned_at)             AS returned,
       CAST(round((julianday(h.returned_at) - julianday(h.issued_at)) * 1440) AS INTEGER) AS minutes_in_use
FROM audio_guide_hire h
JOIN language lang ON lang.language_code = h.language_code
CROSS JOIN params
WHERE date(h.issued_at) = params.day
ORDER BY h.issued_at;
Open in the playground

Question 5

2020 design: not answerable

In what order do visitors most often tour the three wings?

2020: Not answerable. Wings is keyed by the wing name alone, so it can hold at most three rows in total; a second visit to Denon by anyone is a duplicate key. No per-ticket sequence can be stored. 2026: wing_scan stores every scan with its own key, ticket and time.

Answer to question 5
RouteVisitorsPct
Denon1,75718.7
Denon > Sully1,24213.2
Richelieu7357.8
Denon > Richelieu6486.9
Sully5886.3
Richelieu > Denon4174.4
Sully > Richelieu3603.8
Denon > Richelieu > Denon3513.7
Sully > Denon3433.7
Denon > Sully > Denon3333.5
  • Assumes: A route is the sequence of wing scans for one ticket, with consecutive scans of the same wing merged.
Visitors by route
  • Denon1,757
  • Denon > Sully1,242
  • Richelieu735
  • Denon > Richelieu648
  • Sully588
  • Richelieu > Denon417
  • Sully > Richelieu360
  • Denon > Richelieu > Denon351
  • Sully > Denon343
  • Denon > Sully > Denon333
Show the SQL
-- Most common routes through the wings
WITH scans AS (
  SELECT s.ticket_id, s.scanned_at, s.scan_id, s.wing_id,
         lag(s.wing_id) OVER (PARTITION BY s.ticket_id ORDER BY s.scanned_at, s.scan_id) AS previous_wing
  FROM wing_scan s
),
routes AS (
  SELECT sc.ticket_id,
         group_concat(w.name, ' > ' ORDER BY sc.scanned_at, sc.scan_id) AS route
  FROM scans sc
  JOIN wing w ON w.wing_id = sc.wing_id
  WHERE sc.previous_wing IS NULL OR sc.previous_wing <> sc.wing_id
  GROUP BY sc.ticket_id
)
SELECT route,
       count(*) AS visitors,
       round(100.0 * count(*) / (SELECT count(*) FROM routes), 1) AS pct
FROM routes
GROUP BY route
ORDER BY visitors DESC, route
LIMIT 10;
Open in the playground

Question 6

2020 design: not answerable

How many visitors managed to visit all three wings?

2020: Not answerable, for the same reason as question 5: Wings cannot record more than one visit per wing. 2026: Counting distinct wings per ticket in wing_scan answers it directly.

Answer to question 6
YearVisitorsSaw all threePct
20151,51324916.5
20161,49132021.5
20171,72429717.2
20182,17731814.6
20192,24739617.6
20202314619.9
All years9,3831,62617.3
  • Assumes: Counted per admitted ticket, by year of visit, with an all-years total.
Saw all three by year
  • 2015249
  • 2016320
  • 2017297
  • 2018318
  • 2019396
  • 202046
Show the SQL
-- Visitors who scanned into Richelieu, Denon and Sully on their visit
WITH wings_seen AS (
  SELECT ticket_id, count(DISTINCT wing_id) AS wings
  FROM wing_scan
  GROUP BY ticket_id
),
per_year AS (
  SELECT strftime('%Y', v.visit_date) AS year,
         count(*)                    AS visitors,
         sum(ws.wings = 3)           AS saw_all_three
  FROM museum_visit v
  LEFT JOIN wings_seen ws ON ws.ticket_id = v.ticket_id
  GROUP BY year
)
SELECT year, visitors, saw_all_three,
       round(100.0 * saw_all_three / visitors, 1) AS pct
FROM per_year
UNION ALL
SELECT 'All years', sum(visitors), sum(saw_all_three),
       round(100.0 * sum(saw_all_three) / sum(visitors), 1)
FROM per_year;
Open in the playground

Question 7

2020 design: partly

Which ten special exhibitions had the highest attendance over the last five years?

2020: Only indirectly. Hall Napoleon admissions point to a time slot and a ticket but not to an exhibition, so each admission has to be matched back through Exhibition Booking on ticket and slot; admissions without a matching booking are lost. 2026: Each admission belongs to a booking, which belongs to a dated slot of one exhibition.

Answer to question 7
TitleOpens onCloses onAttendance
Leonardo da Vinci: 500th Anniversary2019-10-242020-02-24422
Bodies in Marble2019-02-202019-05-27260
Romantic Fury: French Painting 1820-18502018-03-282018-07-23195
Cities of Clay: Mesopotamia2017-10-042018-01-22170
Light on the Water: Venetian Views2017-02-222017-05-22159
Crossroads: Art of the Mediterranean2018-10-172019-01-21154
Quiet Rooms: Dutch Domestic Painting2016-02-242016-05-30111
Treasures of the Royal Wardrobe2015-03-112015-06-22105
Porcelain for Kings2019-06-122019-09-1675
Paper Masters: Renaissance Drawings2016-06-152016-08-2969
  • Assumes: Attendance is admissions scanned at the Hall Napoleon door, not bookings.
  • Assumes: "Last five years" ends on 2020-02-29.
Attendance by title
  • Leonardo da Vinci: 500th Anniversary422
  • Bodies in Marble260
  • Romantic Fury: French Painting 1820-1850195
  • Cities of Clay: Mesopotamia170
  • Light on the Water: Venetian Views159
  • Crossroads: Art of the Mediterranean154
  • Quiet Rooms: Dutch Domestic Painting111
  • Treasures of the Royal Wardrobe105
  • Porcelain for Kings75
  • Paper Masters: Renaissance Drawings69
Show the SQL
-- Ten best-attended Hall Napoleon exhibitions, last five years
WITH params AS (SELECT '2020-02-29' AS today)
SELECT e.title,
       e.opens_on,
       e.closes_on,
       count(h.scan_id) AS attendance
FROM exhibition e
JOIN exhibition_slot s     ON s.exhibition_id = e.exhibition_id
JOIN exhibition_booking b  ON b.slot_id = s.slot_id
JOIN hall_napoleon_scan h  ON h.booking_id = b.booking_id
CROSS JOIN params
WHERE s.slot_date > date(params.today, '-5 years')
GROUP BY e.exhibition_id
ORDER BY attendance DESC
LIMIT 10;
Open in the playground

Question 9

2020 design: answerable

For large online orders (30 or more tickets), how many tickets were never used?

2020: Answerable on paper: Invoices holds the quantity, tickets point to their invoice, and an unused ticket has no Entrance row. 'Online' sits in EFTPOS.Purchase Channel, reached through Payment Methods, one of the tables MySQL refused to create. 2026: Channel is a column of the order; unused tickets are tickets with no entry scan.

Answer to question 9
Order idOrdered onTicketsUnused
6692015-09-245116
17882016-12-185316
40782019-01-314314
12122016-05-103213
31282018-04-204213
39932018-12-203713
2052015-05-074612
512015-03-204511
2462015-05-204711
18762017-01-224611
20812017-04-183011
23022017-06-283811
12022016-05-085210
41352019-02-185210
48622019-09-074710
Unused tickets across every large online order:
288
  • Assumes: A ticket is unused if it was never scanned at an entrance.
Unused by order id
  • 66916
  • 178816
  • 407814
  • 121213
  • 312813
  • 399313
  • 20512
  • 5111
  • 24611
  • 187611
  • 208111
  • 230211
  • 120210
  • 413510
  • 486210
Show the SQL
-- Large online orders and their unused tickets
SELECT o.order_id,
       date(o.ordered_at)              AS ordered_on,
       l.quantity                      AS tickets,
       sum(v.ticket_id IS NULL)        AS unused,
       sum(sum(v.ticket_id IS NULL)) OVER () AS unused_all_orders
FROM purchase_order o
JOIN order_line l ON l.order_id = o.order_id
JOIN product p    ON p.product_id = l.product_id AND p.code = 'TICKET_ONLINE'
JOIN ticket t     ON t.order_id = o.order_id
LEFT JOIN museum_visit v ON v.ticket_id = t.ticket_id
WHERE o.channel = 'online'
  AND l.quantity >= 30
GROUP BY o.order_id
ORDER BY unused DESC, o.order_id
LIMIT 15;
Open in the playground

Question 10

2020 design: answerable

In the winter months, which weekday morning is the busiest?

2020: Answerable from Entrance Date/Time (the Business Day flag is redundant with the date). A visitor who re-enters before noon would be counted twice; in this data none does, so the 2020 answer matches. 2026: Only first entries count as arrivals, so re-entries do not inflate the morning.

Answer to question 10
WeekdayMorning visitorsMorningsPer morning
Monday183296.31
Tuesday172414.2
Friday133304.43
Wednesday103362.86
Thursday101382.66
  • Assumes: Winter is December to February (Paris).
  • Assumes: Morning is a first entry before 12:00; weekends are excluded.
Morning visitors by weekday
  • Monday183
  • Tuesday172
  • Friday133
  • Wednesday103
  • Thursday101
Show the SQL
-- Winter weekday mornings ranked by visitors entering before noon
SELECT CASE strftime('%w', v.visit_date)
         WHEN '1' THEN 'Monday' WHEN '2' THEN 'Tuesday' WHEN '3' THEN 'Wednesday'
         WHEN '4' THEN 'Thursday' WHEN '5' THEN 'Friday' END AS weekday,
       count(*)                     AS morning_visitors,
       count(DISTINCT v.visit_date) AS mornings,
       round(1.0 * count(*) / count(DISTINCT v.visit_date), 2) AS per_morning
FROM museum_visit v
WHERE CAST(strftime('%m', v.visit_date) AS INTEGER) IN (12, 1, 2)
  AND strftime('%w', v.visit_date) NOT IN ('0', '6')
  AND time(v.first_entry_at) < '12:00:00'
GROUP BY strftime('%w', v.visit_date)
ORDER BY morning_visitors DESC;
Open in the playground