Skip to main content
Marginalia
Basket

Behind the counter

How a shop of twelve thousand books is put together.

Marginalia runs on WooCommerce. The parts customers see — the shelves, the filters, the search box — sit on a catalogue structure that was settled before a single book was listed, because the order you put things in on day one is the thing you cannot cheaply change on day four hundred.

Every number on this page was measured on this shop, on the hardware it actually runs on.The people who build it

Shape

What is a shelf, and what is a label

A book sits in one place on the shelves — three levels deep, thirteen sections down to a hundred and twenty-five shelves. That is the category tree, and it answers "where do I walk to".

Everything else a book is — author, publisher, format, condition, language, decade, reading age, length — is an attribute. Attributes answer "narrow this down". The distinction matters because attributes combine and categories do not: a customer wants a used hardback under ten pounds, and that is three attributes crossing one shelf.

Categories · 180 terms

Fiction → Crime, Thriller & Mystery → Golden Age Crime. One home per book. Drives the address, the breadcrumb and the navigation.

Attributes · 10,580 terms

5,682 authors and 4,842 publishers alone. Never a category — an author is a filter, not a place, and putting them in the tree would make the navigation unusable.

The named views in the navigation — New in, Staff picks, Second-hand, Offers — are not extra categories either. They are saved filters with a readable address, so the shop can add "Signed editions" on a Tuesday without touching the tree.

Filtering

Why the filters do not use the standard product query

WooCommerce filters products by joining the taxonomy tables once per attribute. With one filter that is fine. With four filters, a price range and a sort order it becomes a query the database has to think about — and at twelve thousand titles it thinks for over three seconds.

So the filters run on a purpose-built index instead: one narrow table holding product → facet → value, read with an existence test per condition rather than a join per condition. The catalogue tables stay exactly as WooCommerce expects them, which means every plugin, import and report still works. Rebuilding the whole index takes eight seconds; a single product updates itself the moment it is saved.

What a customer is doing

Standard query

This shop

Difference

Opening one section

1.2ms

0.8ms

1.5×

Section plus format

19.9ms

7.5ms

2.7×

Three attributes, in stock only

371.8ms

8.4ms

44×

Attribute plus price range, sorted

3,216.4ms

2.4ms

1,318×

Five conditions at once

291.2ms

7.4ms

39×

The counts beside each filter option — "Hardback 6,926" — are the expensive part, not the results. Computing them naively means running the whole intersection once per facet. Here the matching set is materialised once into memory and each facet counted against it, which is the difference between 15.6 seconds and 53 milliseconds.

Search

Finding a book by half a title

WordPress search matches with a wildcard scan across post content. It cannot rank, it cannot see custom fields, and it does not find an ISBN at all — the ISBN lives in product metadata, so a customer typing the number off the back of a book gets nothing.

Search here runs on a full-text index over title, author and description, with an exact-code path in front of it for ISBNs and SKUs. Two details decide whether it feels right: short words and stop-words must not be required terms, or "ursula le guin" returns nothing at all; and the fallback has to match word by word rather than as one string.

Query

This shop

Standard search

Results

ursula le guin

1.0ms

73.2ms

8

agatha christie

2.3ms

76.1ms

80

the god of small things

1.2ms

70.6ms

1

9781466496415

0.3ms

68.6ms

1 vs none

Stock

One title, several things you can actually buy

A title is not a thing you can buy. A paperback in very good condition is. So a book with more than one edition on the shelf is a variable product: format crossed with condition gives the buyable rows, each with its own price, stock and barcode. Twelve thousand titles come to 20,871 buyable SKUs.

This is also why the ISBN is the SKU rather than an invented code. ISBN-13 is already the barcode on the back of the book, so the number on the shelf label, the number in the shop's system and the number a scanner reads are the same number.

Second-hand

Stock of one, always. The listing says so, and the copy leaves the shelf the moment it sells rather than going on back-order.

Promotions

A sale price on the row, not a separate "sale" category — so a discounted book keeps its section and its filters, and leaves the offer automatically when the price is cleared.

Day to day

Who is allowed to change what

The hard part of "the shop maintains this itself" is not whether someone can edit a price. It is making sure the person editing a price cannot delete a category — because removing a term takes every book's place on the shelves with it, and nothing brings that back.

So the back office has three roles instead of one. Booksellers work the counter; buyers build the catalogue; the manager holds everything that cannot be undone. Nobody but the owner sees plugins, themes or users.

Bookseller

Prices, stock levels, images, publishing and unpublishing, orders. Cannot delete anything.

Buyer and cataloguer

Adds titles, edits the shelf structure and the attribute lists, runs bulk imports.

Manager

Reports, refunds, coupons, and the destructive operations — including removing a shelf.

Built in

Bulk price changes, stock edits from the product list, and search by ISBN in the admin — the three things that otherwise get done in a spreadsheet.

Growth

What changes on the way to fifty thousand

The structure above does not change as the catalogue grows — that is the point of settling it first. What changes is the machinery underneath, and it changes at predictable sizes rather than all at once.

to 10,000

Nothing beyond what is already here. The index and the full-text search carry this comfortably on ordinary hosting.

10,000–30,000

Object caching in front of the filter counts, and a persistent cache for the category tree. Imports move to a queue, so a ten-thousand-row upload no longer blocks the person who started it.

30,000–50,000

The index is partitioned by facet and the counts are precomputed per category rather than per request. Images move to object storage behind a CDN, because at this size it is the media library, not the database, that makes the admin slow.

beyond

Search moves to a dedicated engine while filtering stays on the index. They are different problems, and putting both in one system makes each of them worse.

Two decisions taken now are what make this possible later, and both are cheap today and expensive to retrofit: never put a filterable property in the category tree, and keep the catalogue tables standard so the fast paths can be added or removed without a migration.

Marginalia is a working reference implementation built by 墨一創意科技 to show how a large catalogue holds together in WooCommerce. Bibliographic records and cover images come from Open Library; prices, stock levels and condition grades are generated for demonstration.

Try the filters on 11,997 titles