Engineering

The malformed record that got in anyway

Every production database holds a few records that should never have been written. A subscription priced at negative four dollars. The same customer email sitting on three separate accounts. You usually find them months later because a report came out wrong, and then you spend an afternoon reconstructing how they got in. The answer is nearly always the same. The form that created them was the only thing checking.

A form is a request, not a rule

Bubble gives every input element a checkbox called "This input should not be empty." The manual is precise about what it does: Bubble "prevents workflows from running until these inputs are filled", and marks the empty input as invalid. That works, and a user who leaves the field blank cannot submit. The open question is what happens when the write does not come through that form at all.

Bubble's own documentation is unusually candid here. The note on disabled inputs reads: "Disabling an input makes it read-only in the UI, but it's not a secure way to prevent edits. Because it's handled client-side, users can still tamper with it." The security guide states the general case more bluntly: "Client-side code is secure from third parties but is controlled by the visitors to the page." Your validation lives in the browser, and the browser belongs to whoever is sitting in front of it.

So Bubble tells you to enforce the real rule elsewhere, using privacy rules, workflow conditions or backend workflows. That advice is correct. It is also the shape of the problem, because each of those is a separate thing you build and then have to remember you built, once per write path. Add an API endpoint six months later and nothing in the editor reminds you the rule exists.

Airtable finds the duplicate after you already have it

Airtable's field types do real work. A date field holds dates, a number field holds numbers, an email field expects an address. That is type checking and it catches a genuine class of mistake. What the field model does not do is constrain values. Nothing in it stops a number field from holding -4 in a business that has no negative prices, and nothing stops two rows carrying the same invoice number.

Uniqueness is the clearest case. Airtable's recommended answer to duplicate records is the dedupe extension, and its support page tells you exactly where that sits in time. You pick a table, pick the fields to compare, choose exact, similar or fuzzy matching, and the extension reports "how many sets of duplicate records are in your table or view". Sets, present tense. They are already in there.

The automation recipe on the same page follows the same logic. Trigger when a record is created, run a Find records action against the key field, check whether more than one result came back, then flag the new record with a checkbox or send yourself a notification. It is a sound recipe and I have built it more than once. But read the sequence carefully: the bad row is written first, detected second, marked third. In the gap between step one and step three, every view and every downstream sync is happily reading a record your business rules say cannot exist.

What a constraint does differently

Postgres puts the rule next to the data. CREATE TABLE products (product_no integer, name text, price numeric CHECK (price > 0)) is the entire implementation, and after that statement runs, no connection can write a negative price. Not the admin panel, not a migration script, not a developer in psql at eleven at night. The documentation adds the detail that gives the game away: the error fires "even if the value came from the default value definition". A constraint does not care who tried.

The same page covers the rest. Not-null, unique, primary key, foreign key, and exclusion constraints that refuse to let two bookings overlap on the same resource. CREATE DOMAIN goes one step further by letting you name a rule once as a reusable type, carrying its own CHECK and NOT NULL, then apply it to every column that needs it. Declare a positive_cents domain in one place and twelve tables inherit it.

Zod covers the other boundary, where data arrives as untrusted JSON. You define the schema once, and User.parse(input) either hands back a validated, typed object or throws. The core is 2kb gzipped with zero external dependencies, and it converts to JSON Schema from the same definition, so the contract your API documents and the contract it enforces come from one source. That gives you two enforcement points, both on your side of the wire, both testable in CI.

Migration is when you meet your own data honestly. Moving Stay World Class off Webflow and Xano onto Next.js, NestJS and Supabase took a four-stage ETL that mapped every legacy ID to a Postgres UUID, and that stage is where the old rows meet the new CHECK constraints. Some of them fail, and you have to work out what each one was meant to say. Tedious, and a cost you pay once. The Lighthouse score going from 55.91 to 91 and LCP dropping 77% is the part people quote back to us. The quieter win was that after cutover, the impossible rows stopped appearing.

When the form really is enough

Plenty of tables never need any of this. If the only people writing to a base are five colleagues who know the conventions, and the cost of a bad row is that somebody fixes it on Tuesday, a database constraint buys you nothing you will ever notice. Internal trackers, content calendars, a hiring pipeline. The dedupe extension exists because catching errors after the fact is a perfectly reasonable strategy when errors are cheap.

Two things change that calculation: who writes, and what a wrong row costs. Once writes arrive from a public form and a partner integration as well as your own UI, the rule has to live somewhere all of them pass through, and the only such place is the server. Once a bad row means a wrong invoice or a double-booked room, finding it on Tuesday stops being a strategy. If neither of those is true for you, stay where you are. The platform is doing its job.

Sources

Is your platform outgrowing its stack?

Book a call and we'll walk through how we'd approach your platform, with an estimate grounded in a working prototype.