Build1 publisher3 min readPublished
One Mutable vat_id Column Is An Audit Failure With A Delay Fuse
A dev.to post makes the case for splitting the customer VAT identifier from an append-only log of validation events, each carrying the consultation number it produced.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened
- The post states that most teams get customer VAT ID storage wrong the same way: they add a single vat_id TEXT column to the customers table, write whatever the user typed, and move on. It was originally published at vatnode.dev, which the author names as the canonical source.
- The design works until an auditor asks what VAT status a customer had on the day you reverse-charged invoice 4471, and the answer, stored in one mutable column that has been overwritten twice since, is gone.
- A customer's VAT ID is a current fact about the customer: it changes when they re-register, move entity, or fix a typo. You want the latest value and you want it to be mutable.
- A validation check is a historical event: it happened at a moment, against a source, and it either produced a consultation number or it did not. You never want to change it after the fact, because the point is to record what was true when you relied on it.
- If the VAT ID column and the was-it-valid column live on the same mutable row, updating the customer's VAT ID silently invalidates the evidence behind every invoice already issued.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A post published on dev.to and republished from vatnode.dev makes a narrow and useful argument: the customer VAT ID design most teams ship, a single mutable `vat_id TEXT` column written with whatever the user typed, works right up until someone asks a dated question [1]. The author's example is the one that lands: an auditor asks what VAT status a customer had on the day you reverse-charged invoice 4471, and the answer lives in one column that has been overwritten twice since [2].
This is a schema problem, not a process problem, because two facts with different lifetimes are being stored in one slot. A customer's VAT ID is a current fact: they re-register, they move entity, they fix a typo, and you want the latest value to be mutable [3]. A validation check is a historical event: it happened at a moment, against a source, and it either produced a consultation number or it did not, and you never want to change it afterwards, because its whole point is recording what was true when you relied on it [4]. Conflate the two and an update to the customer's VAT ID silently invalidates the evidence behind every invoice you have already issued [5]. The fix in the post is one table for the current identifier and latest known status, explicitly allowed to change [6], and a separate append-only table holding the immutable trail of checks [7]. The author frames it as the same split as caching versus storing: Redis holds the fast current answer, PostgreSQL holds the durable evidence [8]. That is a minimum of two storage objects, not one column [9].
The lookup column also has to be normalized before it is useful. EU VAT IDs are case-insensitive alphanumeric strings with a leading two-letter country prefix, so `de 123 456 789`, `DE123456789` and `DE-123-456-789` are the same identifier, and storing them verbatim turns `WHERE vat_id = $1` into a coin flip [10]. Canonical form is uppercase, strip whitespace and punctuation, keep the alphanumerics, applied once before you persist and once before you compare [11]. Keep the raw string as well: it is provenance, and it answers the dispute where a customer says they never entered that number [12]. The country code is then just the first two characters of the normalized value, which removes a separately stored country selection you would otherwise have to keep in sync [13].
Two prefixes are where the post says people break things. Greece is `EL`, not `GR`; the VAT prefix predates the ISO-3166 code and VIES expects `EL`, so rewriting it sends `GR123...` and gets nothing back [14]. Northern Ireland is `XI` after Brexit, a real prefix validatable through VIES rather than a typo for `IE` [15]. Scope is stated as EU-27 plus XI, which is 28 prefixes your normalizer has to leave intact [16], [17].
The post also advises against a fixed length or one universal regex, since length and composition vary by member state; storing a malformed-looking string is fine because it is data, while rejecting a valid ID because your regex was too strict is a bug [18].
What to watch: the published excerpt cuts off mid-`CREATE TABLE customer_vat`, so the column list for the mutable side is not in the version we read [19]. The load-bearing field on the log side is the consultation number, since that is the artefact a check either produced or did not [4]. Before you trust any of this in an audit, check whether your validation client actually returns that number and whether your code persists it on the immutable row rather than discarding it after the boolean.