The two values a database most often gets wrong are money and time, and if you came up through the LAMP years you know exactly how that happens, because we stored currency in FLOAT columns and dates in VARCHAR(10) and then acted surprised when a quarterly report drifted by four cents or a 0000-00-00 row crashed a parser three timezones away. The instinct ever since has been to treat the column type as a hint, something between documentation and a lint rule, and entire databases were built on that idea; SQLite’s type affinity will happily let you declare a column INTEGER and then store the string "hello" in it, and plenty of production systems depend on that flexibility whether they admit it or not. MongrelDB goes the other direction, and it is worth explaining why, because a type at the storage layer is not a courtesy to the developer reading the schema, it is a contract the engine itself cashes in on every scan, every comparison, and every flush to disk.
What a type actually buys the engine
A fixed-width type means the engine can do arithmetic on offsets instead of parsing, which sounds like a small thing until you watch a scan touch a million rows: an INT64 column is eight bytes per value, so row ten thousand starts at byte eighty thousand, no length prefixes, no per-value headers, and the whole column vectorizes into whatever SIMD path the CPU has. MongrelDB’s logical type enum runs the full spread, signed and unsigned integers from 8 to 64 bits, Float32 and Float64, booleans, timestamps at nanosecond precision, DATE32 and DATE64, TIME64, a real INTERVAL, UUID, native JSON, typed arrays, raw bytes, fixed-dimension embeddings, DECIMAL128, and enums, and each one declares its fixed size where it has one so the flush path can pick an Arrow encoding per column based on the type and the runtime stats, dictionary-encoding low-cardinality strings automatically instead of asking you to plan it. The SQL surface maps the names you already know onto this set, so a create-table statement reads the way your muscle memory expects:
CREATE TABLE invoices (
id BIGINT PRIMARY KEY,
customer_id UUID NOT NULL,
total DECIMAL(19, 4) NOT NULL,
status ENUM('open', 'paid', 'void') NOT NULL,
issued_on DATE NOT NULL,
due_at TIMESTAMP,
grace INTERVAL,
line_items ARRAY,
meta JSON
);
The aliases mostly do what you would hope, TEXT and VARCHAR and BLOB land on variable-length bytes, TIMESTAMP lands on nanoseconds, JSON and JSONB land on the native JSON type, and anything the engine does not support is rejected at create time instead of being silently re-typed the way MySQL used to turn your request into whatever it felt like storing. One collapse is worth knowing about up front: every SQL integer alias from TINYINT through BIGINT lands on Int64, because the SQL surface follows the Postgres convention of one canonical integer width, while the narrower signed and unsigned widths from 8 to 32 bits exist for the native and FFI surfaces where the extra byte discipline actually pays, so if you declare a SMALLINT from SQL you get an honest eight-byte column rather than a two-byte column with a check constraint pretending to be a type.
Money is not a float, and never was
The reason DECIMAL128 exists as a first-class type rather than a client-side convention is that floating point money fails in a very specific and very embarrassing way, which is that 0.1 plus 0.2 does not equal 0.3 in binary floating point, and no amount of rounding discipline in application code fixes a column that has already stored the wrong value. MongrelDB stores a decimal as an unscaled 128-bit integer plus a precision and a scale, sixteen bytes fixed-width on disk, so DECIMAL(19, 4) keeps the value 1234.5678 as the integer 12345678 with a scale of four, and addition, subtraction, and comparison stay in integer arithmetic where they belong, with the float conversion only ever happening at the edges where a client insists on seeing one. This is the same trick COBOL was doing with packed decimal in the 1960s and the same trick DECIMAL columns in any serious accounting schema have always done, and the modern equivalent is simply that the engine enforces it in the type itself, so the ORM-of-the-month cannot quietly serialize your invoice total through a JavaScript Number and hand you back a value that fails an audit.
Time is four types, not one
Timestamps get the attention but time has more shapes than that, and collapsing them into one BIGINT of epoch seconds is how you end up with the class of bug where a monthly subscription renews after twenty-eight days because someone divided by the wrong constant. MongrelDB splits the domain the way the domain actually splits: TIMESTAMP is nanoseconds since the epoch for moments, DATE32 and DATE64 cover calendar days without a time component, TIME64 is a nanosecond time-of-day with no date attached, and INTERVAL is stored as three fields, months, days, and nanoseconds, twenty bytes, because a month is not a fixed number of seconds and never will be, so issued_on + INTERVAL '1 month' can mean the same calendar day next month rather than a duration guess that drifts across February. UUIDs get the same treatment at sixteen bytes fixed, stored big-endian so the byte order is the sort order, which means primary-key scans and range comparisons on UUID columns cost what an integer comparison costs instead of what a string comparison costs.
JSON and arrays without giving up the schema
The pitch for document stores in 2010 was that types were a tax on change, and there was some truth in it, but the resolution was never “types or JSON,” it was “types, and JSON where JSON is honest.” A JSON column in MongrelDB is a distinct type from BYTES precisely so the SQL layer knows it can parse and reach inside the value with the JSON functions, while the schema around it stays typed, so meta->>'region' filters on real JSON and total still sums as a decimal in the same query, and you do not have to pick a religion to get both. Arrays are homogeneous and typed at the SQL level, stored as JSON-encoded values in a bytes column with the element type advisory, which is an honest tradeoff worth naming: you give up fixed-width offsets on array elements and in exchange you get a variable-length column that the Kit layer and the query engine can still reason about, and for the genuinely shapeless stuff, the logs and the webhook payloads and the third-party blobs, that is the right corner of the design to spend flexibility in. The one place the engine relaxes enforcement is instructive too, because it is the honest exception to the contract this article opened with: enum membership is validated at the write edge, in SQL coercion and in the HTTP JSON path, rather than on the commit path itself, which keeps the hot commit loop free of checks that the edges have already made redundant, and the price of that choice is that a raw embedded Table::put against the native API can land an out-of-range variant with no engine check, so enum is a type the engine stores but only the edges enforce, and we chose that trade deliberately rather than discovering it in a postmortem.
The honest cost of all this is the one the document-store people warned about, which is that a typed schema means a migration when the shape of your data changes, and there is no type system clever enough to make that free. What you get back is that the wrong-shape failure happens at the write, in the open, with an error that names the column, instead of three quarters later in a finance report, and after twenty years of watching both failure modes I know which one I would rather debug on a Tuesday.
