House-move inventory: Why a spreadsheet wasn't enough - Part 1
~7 min read
We're moving from the UK to France, and the removals company, the French customs form and my own sanity all want the same thing: a list of what is in every box. Not a rough list. Which box is the kettle in? Did every box that left the house actually arrive? What's the declared value of the kitchen? Which boxes are we not taking after all?
The obvious tool is a spreadsheet, and I started with one. This post is about why I stopped, what I built instead, and the few decisions in the first commits that shaped everything after.
Where the spreadsheet broke
A spreadsheet is a fine answer to "list the things". It is a poor answer to the questions that actually come up during a move:
- A box has a life. It is packed, sealed, in transit, arrived, unpacked. Tracking that per row of items means repeating the status on every line, or keeping a second sheet in sync by hand.
- Two people edit at once. Both of us are packing, in different rooms, often from phones.
- The output matters as much as the data. A box needs a contents sheet to tape inside it and a label for the outside. Customs wants invoices attached to the expensive things. None of that is a spreadsheet's job.
- Money. Everything is bought in pounds, the French paperwork wants euros, and a total that silently adds £ and € together is worse than no total.
So: a small web app. Not a general asset-management system - the overview doc says it plainly, "it only needs to survive one move. Favour simplicity over completeness" - but one that models the move properly.
The shape of the data
Three levels, and the hierarchy is the whole idea:
room -> box -> item
Kitchen KIT-01 Frying pan x1, £25.00
A room owns a short prefix (KIT, BED, ...). A box belongs to a room and gets a human-readable code, PREFIX-NN: KIT-01, KIT-02. An item belongs to a box and has a name, a quantity, an optional value and some notes.
The codes matter more than they look. A box code gets written on the side of a cardboard box with a marker, read out over the phone and typed into a search field, so it has to be short, unambiguous and stable. Generating the next one is deliberately simple - take the highest number already used for that prefix and add one:
def _next_seq(self, prefix: str) -> int:
highest = 0
for existing in self._repo.list_box_codes_with_prefix(prefix):
_, _, tail = existing.partition("-")
if tail.isdigit():
highest = max(highest, int(tail))
return highest + 1
Two small behaviours fall out of that. An unknown room name typed into the new-box form registers the room (with a heuristic prefix) rather than failing, so the list of managed rooms always reflects what boxes actually use. And a box's code follows its room's prefix at creation time - a later post covers what happens when you change a prefix afterwards.
Decisions made on day one
The first commits (5 September: "Add docs", "Add initial inventory service", "add api interface pre-refactor") were mostly writing, not coding. The docs came first, and three decisions in them stuck:
- Layers, kept loosely coupled. A Python core with all the domain logic, a thin FastAPI layer over it, a React single-page app over that, and (eventually) a thin CLI beside the API. Each can be tested on its own. The next post is about this.
- SQLite, one file, no cloud. Two people, one move. A single SQLite file that I can back up by copying removes an entire category of operational work. The costs - one writer at a time, schema changes need care - turned out to be real but manageable, and later posts cover both.
- Money is
Decimal, neverfloat. Decided before any item existed. Retrofitting this is the sort of thing that goes wrong quietly.
I also wrote an explicit out of scope list: multiple users and concurrent editing, photos, barcode scanning, currency conversion. It is a mildly embarrassing list to read back, because several of those items shipped within weeks - photos on boxes and items, EUR conversion, and (through someone else's login) multiple users. What the list was really for was getting a usable first version out, and for that it worked: by the end of the first day there was a service that could create boxes, add items, change status, search across boxes and total a box's value.
What "usable" meant
The roadmap I wrote for myself had a rule: each phase should leave something usable. Phase 1 was the core and storage. Phase 2 was the REST API. Phase 3 was the web UI. Then a long tail of numbered phases - 3.5 managed rooms, 3.6 GBP default and EUR conversion, 3.7 clone box, and so on up to 3.37 - each one a small thing I wanted while actually using it. That tail is the real history of the project, and the rest of this series follows it roughly in order.
If you're building something for a one-off event, the lesson I'd pull out of this is boring but true: model the lifecycle of the thing (a box moves through states), not just its contents, and decide early what your identifiers look like, because everything ends up keyed on them.
Next: how the code is organised so that the rules live in exactly one place.
