<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Decimal128 on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/decimal128/</link><description>Recent content in Decimal128 on MongrelDB</description><image><title>MongrelDB</title><url>https://www.mongreldb.com/logo.png</url><link>https://www.mongreldb.com/logo.png</link></image><generator>Hugo</generator><language>en-US</language><lastBuildDate>Wed, 29 Jul 2026 06:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/decimal128/index.xml" rel="self" type="application/rss+xml"/><item><title>Type System Inside a Database: Int64, Decimal128, JSON, Array, Interval</title><link>https://www.mongreldb.com/articles/2026/07/type-system-inside-a-database-int64-decimal128-json-array-interval/</link><pubDate>Wed, 29 Jul 2026 06:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/type-system-inside-a-database-int64-decimal128-json-array-interval/</guid><description>A column type is not documentation, it is a promise the storage engine can spend, and MongrelDB&amp;#39;s type system covers fixed-width integers, decimal128 money, five flavors of time, sortable UUIDs, native JSON, and typed arrays so the engine can plan bytes instead of guessing at strings.</description><content:encoded><![CDATA[<p>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 <code>FLOAT</code> columns and dates in <code>VARCHAR(10)</code> and then acted surprised when a quarterly report drifted by four cents or a <code>0000-00-00</code> 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&rsquo;s type affinity will happily let you declare a column <code>INTEGER</code> and then store the string <code>&quot;hello&quot;</code> 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.</p>
<h2 id="what-a-type-actually-buys-the-engine">What a type actually buys the engine</h2>
<p>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 <code>INT64</code> 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&rsquo;s logical type enum runs the full spread, signed and unsigned integers from 8 to 64 bits, <code>Float32</code> and <code>Float64</code>, booleans, timestamps at nanosecond precision, <code>DATE32</code> and <code>DATE64</code>, <code>TIME64</code>, a real <code>INTERVAL</code>, <code>UUID</code>, native <code>JSON</code>, typed arrays, raw bytes, fixed-dimension embeddings, <code>DECIMAL128</code>, 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:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> invoices (
</span></span><span style="display:flex;"><span>    id          BIGINT <span style="color:#66d9ef">PRIMARY</span> <span style="color:#66d9ef">KEY</span>,
</span></span><span style="display:flex;"><span>    customer_id UUID <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>    total       DECIMAL(<span style="color:#ae81ff">19</span>, <span style="color:#ae81ff">4</span>) <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>    status      ENUM(<span style="color:#e6db74">&#39;open&#39;</span>, <span style="color:#e6db74">&#39;paid&#39;</span>, <span style="color:#e6db74">&#39;void&#39;</span>) <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>    issued_on   DATE <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>    due_at      <span style="color:#66d9ef">TIMESTAMP</span>,
</span></span><span style="display:flex;"><span>    grace       INTERVAL,
</span></span><span style="display:flex;"><span>    line_items  ARRAY,
</span></span><span style="display:flex;"><span>    meta        JSON
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>The aliases mostly do what you would hope, <code>TEXT</code> and <code>VARCHAR</code> and <code>BLOB</code> land on variable-length bytes, <code>TIMESTAMP</code> lands on nanoseconds, <code>JSON</code> and <code>JSONB</code> 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 <code>TINYINT</code> through <code>BIGINT</code> lands on <code>Int64</code>, 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 <code>SMALLINT</code> from SQL you get an honest eight-byte column rather than a two-byte column with a check constraint pretending to be a type.</p>
<h2 id="money-is-not-a-float-and-never-was">Money is not a float, and never was</h2>
<p>The reason <code>DECIMAL128</code> 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 <code>DECIMAL(19, 4)</code> keeps the value <code>1234.5678</code> as the integer <code>12345678</code> 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 <code>DECIMAL</code> 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 <code>Number</code> and hand you back a value that fails an audit.</p>
<h2 id="time-is-four-types-not-one">Time is four types, not one</h2>
<p>Timestamps get the attention but time has more shapes than that, and collapsing them into one <code>BIGINT</code> 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: <code>TIMESTAMP</code> is nanoseconds since the epoch for moments, <code>DATE32</code> and <code>DATE64</code> cover calendar days without a time component, <code>TIME64</code> is a nanosecond time-of-day with no date attached, and <code>INTERVAL</code> 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 <code>issued_on + INTERVAL '1 month'</code> 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.</p>
<h2 id="json-and-arrays-without-giving-up-the-schema">JSON and arrays without giving up the schema</h2>
<p>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 &ldquo;types or JSON,&rdquo; it was &ldquo;types, and JSON where JSON is honest.&rdquo; A <code>JSON</code> column in MongrelDB is a distinct type from <code>BYTES</code> 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 <code>meta-&gt;&gt;'region'</code> filters on real JSON and <code>total</code> 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 <code>Table::put</code> 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.</p>
<p>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.</p>
]]></content:encoded></item></channel></rss>