<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anh Quân Nguyễn</title>
    <description>The latest articles on DEV Community by Anh Quân Nguyễn (@anh_qunnguyn_57549060f).</description>
    <link>https://dev.to/anh_qunnguyn_57549060f</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2189853%2F405c0fa0-9bd1-48d2-b27c-9ad1801775c7.jpg</url>
      <title>DEV Community: Anh Quân Nguyễn</title>
      <link>https://dev.to/anh_qunnguyn_57549060f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anh_qunnguyn_57549060f"/>
    <language>en</language>
    <item>
      <title>What Base64 Actually Does to Your Bytes (and Why It's Not Encryption)</title>
      <dc:creator>Anh Quân Nguyễn</dc:creator>
      <pubDate>Thu, 25 Jun 2026 03:03:54 +0000</pubDate>
      <link>https://dev.to/anh_qunnguyn_57549060f/what-base64-actually-does-to-your-bytes-and-why-its-not-encryption-57ec</link>
      <guid>https://dev.to/anh_qunnguyn_57549060f/what-base64-actually-does-to-your-bytes-and-why-its-not-encryption-57ec</guid>
      <description>&lt;p&gt;Every developer pastes a &lt;code&gt;data:image/png;base64,iVBORw0K...&lt;/code&gt; blob into their CSS at some point, decodes a JWT to see what's inside, or hits a "Basic " auth header and wonders why the password is just... sitting there. Base64 is everywhere in web development, and it's quietly misunderstood by a lot of people who use it daily. Here's what it actually is, what it costs, and the one mistake that shows up in production security reviews.&lt;/p&gt;

&lt;h3&gt;
  
  
  Base64 is a costume for binary, not a lock
&lt;/h3&gt;

&lt;p&gt;The core idea: &lt;strong&gt;Base64 turns arbitrary binary data into plain ASCII text&lt;/strong&gt; so it can travel through channels that only expect text. Email bodies, URLs, JSON values, HTML attributes, and HTTP headers were all designed for text — hand them raw bytes (a PNG, a &lt;code&gt;0x00&lt;/code&gt;, a UTF-16 string) and something downstream mangles or drops them. Base64 re-expresses those bytes using only safe, printable characters that nothing along the way will touch.&lt;/p&gt;

&lt;p&gt;The alphabet is exactly 64 characters: &lt;code&gt;A–Z&lt;/code&gt;, &lt;code&gt;a–z&lt;/code&gt;, &lt;code&gt;0–9&lt;/code&gt;, plus &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;. That's 26 + 26 + 10 + 2 = 64. There's also &lt;code&gt;=&lt;/code&gt;, used only for padding (more on that below). Sixty-four characters is the whole trick — and it's where the name comes from.&lt;/p&gt;

&lt;p&gt;Critically: &lt;strong&gt;Base64 is encoding, not encryption.&lt;/strong&gt; It provides zero secrecy. Anyone can reverse it instantly — there's no key. If you've ever "hidden" a credential by Base64-encoding it, you've hidden nothing; you've just made it slightly less obvious to a human skimming, and completely obvious to literally any tool. We'll come back to why that matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 3-bytes-to-4-characters math
&lt;/h3&gt;

&lt;p&gt;Here's the mechanism, and it explains everything else about Base64's behavior.&lt;/p&gt;

&lt;p&gt;A byte is 8 bits. A Base64 character represents 6 bits (because 2⁶ = 64, exactly enough to index the alphabet). The least common multiple of 8 and 6 is 24, so Base64 works in groups of &lt;strong&gt;3 bytes (24 bits) → 4 characters (4 × 6 bits)&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input:   3 bytes   = 24 bits
Regroup: 4 × 6-bit chunks
Output:  4 Base64 characters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take the bytes for &lt;code&gt;Cat&lt;/code&gt; (&lt;code&gt;0x43 0x61 0x74&lt;/code&gt;), line up the 24 bits, slice them into four 6-bit numbers instead of three 8-bit ones, and look each up in the alphabet → &lt;code&gt;Q2F0&lt;/code&gt;. That's the entire algorithm: regroup bits from 8-wide to 6-wide and map to characters.&lt;/p&gt;

&lt;p&gt;Two consequences fall out of this immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Base64 makes data ~33% bigger.&lt;/strong&gt; Every 3 bytes in become 4 characters out, so output is &lt;code&gt;4/3 ≈ 1.33×&lt;/code&gt; the input size. That's the price of text-safety. A 3 MB image becomes ~4 MB of Base64 text. This is exactly why you &lt;em&gt;don't&lt;/em&gt; Base64 large assets into your HTML/CSS — you inflate the payload by a third and make it un-cacheable as a separate file. You can watch this overhead directly by pasting text into a &lt;a href="https://calculators.im/base64-encoder-decoder" rel="noopener noreferrer"&gt;Base64 encoder/decoder&lt;/a&gt; and comparing input vs output length.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Padding (&lt;code&gt;=&lt;/code&gt;) fills the gaps.&lt;/strong&gt; If your data isn't a clean multiple of 3 bytes, the last group is short. Base64 pads the output to a multiple of 4 characters using &lt;code&gt;=&lt;/code&gt;: one &lt;code&gt;=&lt;/code&gt; means the last group had 2 bytes, two &lt;code&gt;==&lt;/code&gt; means it had 1 byte. So a trailing &lt;code&gt;==&lt;/code&gt; is normal and expected — it's not corruption, it's the encoder telling the decoder how many real bytes the final group held.&lt;/p&gt;

&lt;h3&gt;
  
  
  The URL-safe variant you'll meet in JWTs
&lt;/h3&gt;

&lt;p&gt;Standard Base64 uses &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;. Both are a problem in URLs (&lt;code&gt;/&lt;/code&gt; is a path separator) and in filenames. So there's a second flavor, &lt;strong&gt;Base64URL&lt;/strong&gt;, that swaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;+&lt;/code&gt; → &lt;code&gt;-&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/&lt;/code&gt; → &lt;code&gt;_&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;and usually drops the &lt;code&gt;=&lt;/code&gt; padding entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what you see inside a JSON Web Token. A JWT is just three Base64URL strings joined by dots: &lt;code&gt;header.payload.signature&lt;/code&gt;. Decode the first two segments and you get plain JSON — which is the second reason people get burned. A JWT payload is &lt;strong&gt;readable by anyone&lt;/strong&gt;, because Base64URL isn't encryption either. The signature protects against &lt;em&gt;tampering&lt;/em&gt;, not against &lt;em&gt;reading&lt;/em&gt;. Never put secrets in a JWT payload. If you want to eyeball what's in one, decode the segment and run it through a &lt;a href="https://calculators.im/json-formatter" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; to pretty-print the claims.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Base64 genuinely earns its keep
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data URIs&lt;/strong&gt; — inlining a tiny icon or font directly in CSS/HTML (&lt;code&gt;url(data:image/svg+xml;base64,...)&lt;/code&gt;) to save an HTTP request. Good for &lt;em&gt;small&lt;/em&gt; assets only, because of the 33% tax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email attachments (MIME)&lt;/strong&gt; — the original use case; SMTP is a text protocol, so binary attachments are Base64-encoded (historically wrapped at 76 characters per line).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Basic auth&lt;/strong&gt; — &lt;code&gt;Authorization: Basic &amp;lt;base64(user:pass)&amp;gt;&lt;/code&gt;. This is why Basic auth over plain HTTP is dangerous: the credentials are &lt;em&gt;encoded&lt;/em&gt;, not encrypted, so anyone on the wire reads them. Always pair it with HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding binary in JSON/XML&lt;/strong&gt; — JSON has no binary type, so byte blobs (small images, hashes, keys) get Base64'd into string fields.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The mistakes that show up in code review
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treating Base64 as security.&lt;/strong&gt; Encoding ≠ encryption ≠ hashing. If the goal is secrecy, you need actual cryptography; if it's integrity, you need a hash or signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base64-ing huge files.&lt;/strong&gt; The 33% bloat plus the memory cost of holding both the binary and its text form will hurt. Stream or upload the raw bytes instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing the two alphabets.&lt;/strong&gt; Feeding standard Base64 (&lt;code&gt;+&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;) into a Base64URL decoder (or vice versa) fails or corrupts. Match the variant to the context — URLs and JWTs want Base64URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Panicking over &lt;code&gt;=&lt;/code&gt;.&lt;/strong&gt; Trailing padding is normal. Some systems strip it (and re-add it on decode); that's fine as long as both ends agree.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;Base64 is a simple, elegant bit-regrouping scheme: 8-bit bytes re-sliced into 6-bit chunks so binary can ride through text-only pipes. It costs you a third more size and buys you universal compatibility — a great trade for small assets, MIME, and JSON, a bad one for big files. The single thing to burn into memory: it is &lt;strong&gt;not&lt;/strong&gt; a security mechanism. Anything Base64-encoded is plainly readable by anyone who cares to look.&lt;/p&gt;

&lt;p&gt;Next time you see a &lt;code&gt;data:&lt;/code&gt; URI or a dotted JWT, you'll know exactly what those characters are — and that you could &lt;a href="https://calculators.im/base64-encoder-decoder" rel="noopener noreferrer"&gt;decode them yourself&lt;/a&gt; in a second.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Author bio: Quan Nguyen builds free, no-signup developer tools at &lt;a href="https://calculators.im/base64-encoder-decoder" rel="noopener noreferrer"&gt;calculators.im&lt;/a&gt;, including a Base64 encoder/decoder and a JSON formatter.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>base64</category>
      <category>webdev</category>
      <category>programming</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>How UUIDs Actually Work: v4 Randomness, v7 Timestamps, and the Collision Math</title>
      <dc:creator>Anh Quân Nguyễn</dc:creator>
      <pubDate>Mon, 22 Jun 2026 02:47:00 +0000</pubDate>
      <link>https://dev.to/anh_qunnguyn_57549060f/how-uuids-actually-work-v4-randomness-v7-timestamps-and-the-collision-math-49pp</link>
      <guid>https://dev.to/anh_qunnguyn_57549060f/how-uuids-actually-work-v4-randomness-v7-timestamps-and-the-collision-math-49pp</guid>
      <description>&lt;p&gt;Every backend developer types &lt;code&gt;uuid()&lt;/code&gt; a thousand times before ever asking what those 36 characters actually are. Then one day a senior engineer says "stop using v4 for your primary key, it's wrecking the index," and suddenly the thing you treated as a magic random string has &lt;em&gt;versions&lt;/em&gt;, &lt;em&gt;trade-offs&lt;/em&gt;, and a surprising amount of math underneath. Here is the whole picture.&lt;/p&gt;

&lt;h3&gt;
  
  
  A UUID is 128 bits wearing a costume
&lt;/h3&gt;

&lt;p&gt;A UUID is just a 128-bit number. The familiar form — &lt;code&gt;f47ac10b-58cc-4372-a567-0e02b2c3d479&lt;/code&gt; — is those 128 bits written as 32 hexadecimal digits, split into groups of &lt;code&gt;8-4-4-4-12&lt;/code&gt; with dashes for readability. The dashes carry no information; they're punctuation.&lt;/p&gt;

&lt;p&gt;But not all 128 bits are free. Two small fields are reserved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 version bits&lt;/strong&gt; — which UUID &lt;em&gt;type&lt;/em&gt; this is (the &lt;code&gt;4&lt;/code&gt; in &lt;code&gt;...-4372-...&lt;/code&gt; marks a version 4).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 variant bits&lt;/strong&gt; — which UUID &lt;em&gt;spec&lt;/em&gt; it follows (almost always RFC 9562, the 2024 standard that replaced the old RFC 4122).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a "random" UUID isn't 128 random bits. It's &lt;strong&gt;122 random bits&lt;/strong&gt; plus 6 bits of bookkeeping. That number matters later, so hold onto it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The versions you'll actually meet
&lt;/h3&gt;

&lt;p&gt;There are several versions, but three show up in real systems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version 1 — timestamp + MAC address.&lt;/strong&gt; A 60-bit timestamp (100-nanosecond ticks since October 1582, of all dates) combined with a clock sequence and the machine's network MAC address. It's sortable by creation time, but it leaks &lt;em&gt;where&lt;/em&gt; and &lt;em&gt;when&lt;/em&gt; it was made — the MAC address is literally embedded. That privacy footgun is why v1 fell out of fashion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version 4 — (almost) all random.&lt;/strong&gt; 122 random bits, no timestamp, no machine identity. It became the default because it's dead simple: no shared state, no coordination, any process can mint one offline and trust it's unique. This is what most &lt;code&gt;uuid&lt;/code&gt; libraries hand you by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version 7 — timestamp + randomness, done right.&lt;/strong&gt; Standardized in RFC 9562 (2024). The first 48 bits are a Unix millisecond timestamp; the remaining bits (minus version/variant) are random. You get v4's "generate anywhere, no coordination" property &lt;em&gt;and&lt;/em&gt; v1's time-ordering — without leaking a MAC address. v7 is the one the industry is quietly migrating to, for reasons that become obvious once you look at databases.&lt;/p&gt;

&lt;h3&gt;
  
  
  The collision question everyone asks about v4
&lt;/h3&gt;

&lt;p&gt;"If v4 is random, won't two eventually be the same?" Yes — in the same sense that you &lt;em&gt;might&lt;/em&gt; win the lottery twice on the same day. The interesting part is the actual scale.&lt;/p&gt;

&lt;p&gt;With 122 random bits, there are &lt;code&gt;2^122 ≈ 5.3 × 10^36&lt;/code&gt; possible v4 UUIDs. Naively you'd think you're safe until you've generated half of those. But collisions follow the &lt;strong&gt;birthday paradox&lt;/strong&gt;: the chance two values match grows with the &lt;em&gt;square&lt;/em&gt; of how many you generate, not linearly. The rule of thumb is that you reach a ~50% chance of &lt;em&gt;any&lt;/em&gt; collision after roughly the square root of the space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50% collision  ≈  1.18 × √(2^122)  ≈  2.7 × 10^18 UUIDs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 2.7 &lt;em&gt;quintillion&lt;/em&gt;. To actually hit a coin-flip's worth of collision risk, you'd have to generate a billion UUIDs per second for about 85 years straight. For any normal application, v4 collisions are a non-event — you'll lose data to disk failure, bad migrations, and off-by-one bugs long before randomness betrays you.&lt;/p&gt;

&lt;p&gt;If you want to feel the birthday-paradox effect for your own numbers — say, "what's the collision risk at 10 billion rows?" — it's the same combinatorics that powers lottery odds and hash-collision estimates; you can plug values into a &lt;a href="https://calculators.im/permutation-combination-calculator" rel="noopener noreferrer"&gt;permutation and combination calculator&lt;/a&gt; and watch how fast the probability climbs with the square of the count. It's a good intuition pump for &lt;em&gt;why&lt;/em&gt; hash sizes and ID widths are chosen the way they are.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why v4 quietly hurts your database
&lt;/h3&gt;

&lt;p&gt;Here's the part that bites teams in production. If you make a v4 UUID your &lt;strong&gt;primary key&lt;/strong&gt; on a database that clusters rows by primary key (InnoDB in MySQL, and effectively most B-tree primary indexes), every insert lands at a &lt;em&gt;random&lt;/em&gt; position in the index.&lt;/p&gt;

&lt;p&gt;Random insert positions mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page splits everywhere.&lt;/strong&gt; New rows don't append to the end; they wedge into the middle of already-full index pages, forcing the engine to split them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache thrashing.&lt;/strong&gt; Recently inserted rows are scattered across the whole index, so the hot pages your buffer pool wants to keep in memory keep changing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index bloat and fragmentation.&lt;/strong&gt; Over millions of rows, the index grows larger and slower than a sequential key would.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An auto-increment integer never has this problem because every insert appends to the end. The tragedy of v4 is that you adopt it for the distributed, coordination-free generation — then pay for it in write amplification on a single database.&lt;/p&gt;

&lt;h3&gt;
  
  
  How v7 fixes it without giving up distribution
&lt;/h3&gt;

&lt;p&gt;Version 7 puts a millisecond timestamp in the &lt;strong&gt;high&lt;/strong&gt; bits. Because index ordering reads left to right, UUIDs generated close in time sort close together. Inserts become &lt;em&gt;mostly sequential&lt;/em&gt; — new rows append near the end of the index, just like an auto-increment key — while the trailing random bits still guarantee uniqueness across processes with no shared counter.&lt;/p&gt;

&lt;p&gt;So v7 gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Append-friendly inserts (no random page splits)&lt;/li&gt;
&lt;li&gt;Time-sortable IDs for free (great for "newest first" queries and pagination)&lt;/li&gt;
&lt;li&gt;No central coordinator, no MAC leak&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why "use v7 for primary keys, v4 only when you specifically want unpredictability" is becoming the standard advice. (ULID and KSUID are earlier, non-standard takes on the same time-prefix idea; v7 is the official version of that pattern.)&lt;/p&gt;

&lt;h3&gt;
  
  
  A practical cheat sheet
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public-facing or security-sensitive IDs&lt;/strong&gt; (password-reset tokens, share links): you usually want &lt;em&gt;unpredictability&lt;/em&gt;, so v4 — or a dedicated cryptographic token, not a UUID at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database primary keys&lt;/strong&gt;: prefer &lt;strong&gt;v7&lt;/strong&gt; for index locality; fall back to auto-increment if you don't need distributed generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; parse meaning out of a UUID. Don't assume v1's timestamp, don't sort v4s expecting order, and don't treat a UUID as a secret just because it looks random.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt;: store the 128 bits as a &lt;code&gt;UUID&lt;/code&gt;/&lt;code&gt;BINARY(16)&lt;/code&gt; column, not a 36-char string — you're otherwise spending 36 bytes to hold 16 bytes of data and slowing every index comparison.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;A UUID isn't a random string; it's a 128-bit number with 6 reserved bits and a version that decides everything about how it behaves. v4 is random and collision-proof in practice but hostile to clustered indexes; v7 keeps the coordination-free magic while sorting by time so your database stops fighting you. Pick the version for the job instead of letting the library default decide.&lt;/p&gt;

&lt;p&gt;If you just need a few to test with, you can &lt;a href="https://calculators.im/uuid-generator" rel="noopener noreferrer"&gt;generate UUIDs here&lt;/a&gt; — and next time someone on your team says "just use a UUID," you'll know which one they should mean.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Author bio: Quan Nguyen builds free, no-signup developer tools at &lt;a href="https://calculators.im/uuid-generator" rel="noopener noreferrer"&gt;calculators.im&lt;/a&gt;, including a UUID generator and a subnet calculator.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>backend</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>How `git diff` Actually Works: The Myers Algorithm in Plain English</title>
      <dc:creator>Anh Quân Nguyễn</dc:creator>
      <pubDate>Fri, 19 Jun 2026 07:44:39 +0000</pubDate>
      <link>https://dev.to/anh_qunnguyn_57549060f/how-git-diff-actually-works-the-myers-algorithm-in-plain-english-3h9d</link>
      <guid>https://dev.to/anh_qunnguyn_57549060f/how-git-diff-actually-works-the-myers-algorithm-in-plain-english-3h9d</guid>
      <description>&lt;p&gt;You run &lt;code&gt;git diff&lt;/code&gt; dozens of times a day. You read the red and green lines, you stage the hunks, you move on. But there's a small algorithmic miracle happening every time: out of the astronomically many ways to describe "how file A became file B," the tool quietly finds one of the &lt;em&gt;shortest&lt;/em&gt; ones.&lt;/p&gt;

&lt;p&gt;That's not a formatting trick. It's a shortest-path search. Once you see it, code review, merge conflicts, and "why did git think I moved this whole block?" all start to make sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  A diff is the shortest edit script
&lt;/h2&gt;

&lt;p&gt;Forget the colored output. A diff between two sequences of lines — &lt;code&gt;A&lt;/code&gt; (length &lt;code&gt;N&lt;/code&gt;) and &lt;code&gt;B&lt;/code&gt; (length &lt;code&gt;M&lt;/code&gt;) — is an &lt;strong&gt;edit script&lt;/strong&gt;: a list of insertions and deletions that turns &lt;code&gt;A&lt;/code&gt; into &lt;code&gt;B&lt;/code&gt;. There are always many valid scripts. The dumbest one is "delete all of A, insert all of B" — technically correct, completely useless.&lt;/p&gt;

&lt;p&gt;What you actually want is the script with the fewest edits, because the lines you &lt;em&gt;didn't&lt;/em&gt; touch are the ones that stayed the same. So the real goal is the mirror image:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Find the longest common subsequence (LCS) of A and B. Everything in it is "unchanged"; everything else is an insert or a delete.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The two framings are the same problem. If the LCS has length &lt;code&gt;L&lt;/code&gt;, the minimum number of edits is exactly &lt;code&gt;N + M - 2L&lt;/code&gt;. Maximize the matches, minimize the edits. That number — the edit distance — is what a diff is really computing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious approaches fail
&lt;/h2&gt;

&lt;p&gt;The first instinct is line-by-line: walk both files in lockstep, mark lines that differ. That breaks the instant you insert a single line near the top — everything below shifts by one and the naive walk reports the entire rest of the file as changed. Useless for code review.&lt;/p&gt;

&lt;p&gt;The textbook fix is the LCS dynamic-programming table: an &lt;code&gt;(N+1) × (M+1)&lt;/code&gt; grid, fill it in, backtrack. It's correct, but it's &lt;code&gt;O(N × M)&lt;/code&gt; time &lt;em&gt;and&lt;/em&gt; memory. For two 10,000-line files that's 100 million cells. Git can't afford that on every &lt;code&gt;status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The insight that makes real diffs practical: &lt;strong&gt;edited files are usually similar.&lt;/strong&gt; Most of the lines match. So instead of paying for the whole grid, pay only for the &lt;em&gt;changes&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Myers: a diff is a shortest path through an edit graph
&lt;/h2&gt;

&lt;p&gt;The algorithm Git uses by default is &lt;strong&gt;Eugene Myers' 1986 diff&lt;/strong&gt; — and the whole thing rests on one reframing. Lay file &lt;code&gt;A&lt;/code&gt; along the top of a grid and file &lt;code&gt;B&lt;/code&gt; down the side. Now you're navigating from the top-left corner &lt;code&gt;(0,0)&lt;/code&gt; to the bottom-right &lt;code&gt;(N,M)&lt;/code&gt; with three moves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;→  move right   = delete a line from A
↓  move down    = insert a line from B
↘  move diagonal = the two lines match (FREE)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A diagonal move is only allowed when &lt;code&gt;A[x] == B[y]&lt;/code&gt;, and — this is the key — it's &lt;strong&gt;free&lt;/strong&gt;. Right and down moves each cost 1. So:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The minimum diff is the cheapest path from corner to corner, and "cheapest" means "takes the most free diagonals."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A diff is a shortest-path problem. The long diagonal runs — Myers calls them &lt;strong&gt;snakes&lt;/strong&gt; — are your unchanged blocks. The horizontal and vertical steps between them are the hunks you see in red and green.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: search by number of edits, not by grid size
&lt;/h2&gt;

&lt;p&gt;Myers doesn't fill the grid. He does a breadth-first search ordered by &lt;code&gt;D&lt;/code&gt; = the number of edits so far (&lt;code&gt;D = 0, 1, 2, …&lt;/code&gt;), and at each level tracks only the &lt;strong&gt;furthest-reaching path on each diagonal&lt;/strong&gt; &lt;code&gt;k = x - y&lt;/code&gt;. Greedily slide down every free diagonal you can before spending another edit. The first time a path reaches &lt;code&gt;(N,M)&lt;/code&gt;, the &lt;code&gt;D&lt;/code&gt; that got it there &lt;em&gt;is&lt;/em&gt; the edit distance, and the snakes along the way are the diff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;D = 0:  try to snake straight down the main diagonal from (0,0)
D = 1:  spend one edit (one right OR one down), then snake as far as possible
D = 2:  spend two edits, snake again
...
stop the moment a path touches (N, M)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it stops at the true edit distance &lt;code&gt;D&lt;/code&gt;, the cost is &lt;code&gt;O(N × D)&lt;/code&gt; — proportional to the file size times the &lt;em&gt;number of changes&lt;/em&gt;, not the product of the two file sizes. When &lt;code&gt;D&lt;/code&gt; is small (the normal case: a few edited lines in a big file), it's effectively linear. When you paste a wildly different file, &lt;code&gt;D&lt;/code&gt; blows up and it degrades gracefully toward the DP cost. That "fast when similar" property is exactly what you want from a tool that runs on every keystroke in your editor's gutter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the same idea shows up
&lt;/h2&gt;

&lt;p&gt;Once a diff is "shortest path that maximizes free diagonals," you spot the pattern everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git diff&lt;/code&gt;, &lt;code&gt;git blame&lt;/code&gt;, merge:&lt;/strong&gt; all built on this edit-script core. The three-way merge that resolves your conflicts is two diffs against a common ancestor, reconciled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code review tools&lt;/strong&gt; (GitHub, GitLab): the side-by-side view is the edit graph rendered as two columns; the connecting highlights are the snakes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text/JSON/config comparison:&lt;/strong&gt; comparing two API responses or two YAML files is the same LCS problem on lines or tokens. (Pro tip: pretty-print or sort keys on both sides &lt;em&gt;first&lt;/em&gt; — a noisy diff is usually just two differently-formatted versions of identical data. Running each side through a &lt;a href="https://calculators.im/json-formatter" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; before comparing collapses the diff down to the changes that actually matter.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rsync&lt;/code&gt; / binary deltas:&lt;/strong&gt; same shortest-edit goal, different granularity (blocks instead of lines).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bioinformatics:&lt;/strong&gt; sequence alignment of DNA is LCS with weighted moves. Same grid, fancier costs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The catch: a &lt;em&gt;minimal&lt;/em&gt; diff isn't always a &lt;em&gt;readable&lt;/em&gt; one
&lt;/h2&gt;

&lt;p&gt;Here's the part that bites people. Myers finds &lt;em&gt;a&lt;/em&gt; shortest edit script — but "fewest edits" and "what a human meant" aren't always the same. Move a function and the minimal diff might pair the closing &lt;code&gt;}&lt;/code&gt; of your function with the &lt;code&gt;}&lt;/code&gt; of the &lt;em&gt;next&lt;/em&gt; one, producing that maddening hunk where braces and signatures are interleaved nonsense. The algorithm isn't wrong; minimizing edit count just doesn't know your code has structure. (This is why Git ships a &lt;code&gt;--diff-algorithm=patience&lt;/code&gt; and &lt;code&gt;histogram&lt;/code&gt; — different heuristics for choosing &lt;em&gt;which&lt;/em&gt; minimal-ish diff reads best.)&lt;/p&gt;

&lt;p&gt;So when a diff looks insane, it's not a bug. It's the shortest path honestly disagreeing with your sense of meaning. Switching algorithms, or eyeballing it in a clean side-by-side view that lets you flip between unified and split layouts, usually makes the human-intended change pop back out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Next time you read a diff, don't think "it compared the lines." Think &lt;strong&gt;"it found the cheapest path through an edit graph, taking every free match it could."&lt;/strong&gt; The green and red are the edits; the silent unchanged lines are the snakes the algorithm worked hard to keep. A diff isn't a comparison — it's a shortest-path proof that &lt;em&gt;these&lt;/em&gt; few changes are all it took to get from A to B.&lt;/p&gt;

&lt;p&gt;That's the whole idea behind the tool you trust with every commit. Not magic — just a very clever way of counting halts on a grid.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you want to watch the snakes and edits line up on real input, I keep a free, privacy-first &lt;a href="https://calculators.im/diff-checker" rel="noopener noreferrer"&gt;diff checker&lt;/a&gt; that runs entirely in the browser (no upload) with unified and side-by-side views — handy for eyeballing exactly which lines the algorithm called "changed."&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>algorithms</category>
      <category>computerscience</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The Math Behind O(log n): Binary Search, log , and Why Halving Wins</title>
      <dc:creator>Anh Quân Nguyễn</dc:creator>
      <pubDate>Fri, 12 Jun 2026 10:15:58 +0000</pubDate>
      <link>https://dev.to/anh_qunnguyn_57549060f/the-math-behind-olog-n-binary-search-log2-and-why-halving-wins-2om4</link>
      <guid>https://dev.to/anh_qunnguyn_57549060f/the-math-behind-olog-n-binary-search-log2-and-why-halving-wins-2om4</guid>
      <description>&lt;p&gt;You have read &lt;code&gt;O(log n)&lt;/code&gt; a hundred times. Binary search is &lt;code&gt;O(log n)&lt;/code&gt;. A balanced BST lookup is &lt;code&gt;O(log n)&lt;/code&gt;. Heap insert is &lt;code&gt;O(log n)&lt;/code&gt;. We nod along — &lt;code&gt;log n&lt;/code&gt; means "fast" — and move on.&lt;/p&gt;

&lt;p&gt;But what &lt;em&gt;is&lt;/em&gt; that logarithm actually counting? Once it clicks, a whole family of algorithms stops being trivia you memorized and becomes one idea you understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  A logarithm counts halvings
&lt;/h2&gt;

&lt;p&gt;Forget the textbook definition for a second. Here's the one that matters for algorithms:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;log₂(n) is the number of times you can halve n before you reach 1.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. Start with &lt;code&gt;n&lt;/code&gt;, keep dividing by 2, count the steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;64 → 32 → 16 → 8 → 4 → 2 → 1     = 6 steps   →  log₂(64) = 6
1000 → 500 → ... → ~1            ≈ 10 steps  →  log₂(1000) ≈ 9.97
1,000,000 → ...                 ≈ 20 steps  →  log₂(1,000,000) ≈ 19.93
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The headline number every engineer should have burned into memory: &lt;strong&gt;log₂ of a million is about 20.&lt;/strong&gt; A billion is about 30. The input grew by 1000×, the work grew by 10. That gap is the entire reason &lt;code&gt;O(log n)&lt;/code&gt; feels like magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Binary search is just "halve until found"
&lt;/h2&gt;

&lt;p&gt;Binary search is the canonical halving algorithm. Each comparison throws away half of what's left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;binary_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;      &lt;span class="c1"&gt;# discard the lower half
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;      &lt;span class="c1"&gt;# discard the upper half
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on a sorted array of one million integers and &lt;code&gt;steps&lt;/code&gt; never exceeds 20 — no matter which element you search for. A linear scan would average 500,000 comparisons. Same data, same machine, a 25,000× difference in worst-case work. The only thing that changed is that binary search &lt;em&gt;halves&lt;/em&gt; while the scan &lt;em&gt;decrements&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's the whole trick: &lt;strong&gt;decrementing gives you O(n); halving gives you O(log n).&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where halving shows up once you see it
&lt;/h2&gt;

&lt;p&gt;The moment you read &lt;code&gt;O(log n)&lt;/code&gt; as "halving," you start spotting the same move everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Balanced trees (AVL, red-black, B-trees):&lt;/strong&gt; each level splits the remaining keys in two, so the tree is ~log₂(n) deep. A B-tree with a high branching factor &lt;code&gt;b&lt;/code&gt; is &lt;code&gt;log_b(n)&lt;/code&gt; deep — same idea, fatter halving. That's why a 4-level B-tree can index millions of rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binary heaps:&lt;/strong&gt; sift-up and sift-down walk one root-to-leaf path. Path length = tree height = &lt;code&gt;O(log n)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divide and conquer:&lt;/strong&gt; merge sort splits the array in half each level, giving &lt;code&gt;log₂(n)&lt;/code&gt; levels of &lt;code&gt;O(n)&lt;/code&gt; work → &lt;code&gt;O(n log n)&lt;/code&gt;. The &lt;code&gt;log&lt;/code&gt; factor &lt;em&gt;is&lt;/em&gt; the number of splits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bits:&lt;/strong&gt; the number of bits to represent &lt;code&gt;n&lt;/code&gt; is &lt;code&gt;⌈log₂(n+1)⌉&lt;/code&gt;. "How many bits?" and "how many halvings?" are literally the same question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential search / doubling:&lt;/strong&gt; when you double a buffer or probe &lt;code&gt;1, 2, 4, 8, …&lt;/code&gt;, you reach &lt;code&gt;n&lt;/code&gt; in &lt;code&gt;log₂(n)&lt;/code&gt; steps. Halving, run backwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  "But my data isn't a power of two"
&lt;/h2&gt;

&lt;p&gt;Real inputs aren't clean powers of 2, and the base isn't always 2 — a ternary split is &lt;code&gt;log₃&lt;/code&gt;, a B-tree with branching factor 256 is &lt;code&gt;log₂₅₆&lt;/code&gt;. When you need the actual value for a back-of-the-envelope estimate, you lean on the &lt;strong&gt;change-of-base formula&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log_b(n) = ln(n) / ln(b) = log₁₀(n) / log₁₀(b)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the depth of a B-tree holding 10 million keys with a branching factor of 256 is &lt;code&gt;log₂₅₆(10,000,000) = ln(10,000,000) / ln(256) ≈ 16.1 / 5.55 ≈ 2.9&lt;/code&gt; — three levels. You can punch that into any &lt;a href="https://calculators.im/logarithm-calculator" rel="noopener noreferrer"&gt;logarithm calculator&lt;/a&gt; that supports an arbitrary base instead of reaching for a language's &lt;code&gt;math.log(x, base)&lt;/code&gt; mid-discussion. The point isn't the tool; it's that &lt;em&gt;base is just branching factor&lt;/em&gt;, and change-of-base lets you compare a binary split against a 256-way split on the same axis.&lt;/p&gt;

&lt;p&gt;One more identity worth keeping: in Big-O, &lt;strong&gt;the base doesn't matter&lt;/strong&gt;. &lt;code&gt;log₂(n)&lt;/code&gt; and &lt;code&gt;log₁₀(n)&lt;/code&gt; differ only by the constant factor &lt;code&gt;1/ln(2)&lt;/code&gt; vs &lt;code&gt;1/ln(10)&lt;/code&gt;, and Big-O eats constants. That's why we write &lt;code&gt;O(log n)&lt;/code&gt; with no base at all — the &lt;em&gt;existence&lt;/em&gt; of halving is what we're claiming, not its flavor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Next time you read &lt;code&gt;O(log n)&lt;/code&gt;, don't translate it to "fast." Translate it to &lt;strong&gt;"this algorithm halves the problem every step,"&lt;/strong&gt; and ask &lt;em&gt;where&lt;/em&gt; the halving happens — the comparison, the tree level, the recursive split, the doubling buffer. That single reframing turns binary search, balanced trees, heaps, and divide-and-conquer into variations on one theme instead of four things to memorize.&lt;/p&gt;

&lt;p&gt;Halving is the cheapest superpower in computer science. Logarithms are just how we count it.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>computerscience</category>
      <category>beginners</category>
      <category>maths</category>
    </item>
    <item>
      <title>Time Math Is Harder Than It Looks: 6 Duration Bugs and How to Avoid Them</title>
      <dc:creator>Anh Quân Nguyễn</dc:creator>
      <pubDate>Sat, 06 Jun 2026 22:22:17 +0000</pubDate>
      <link>https://dev.to/anh_qunnguyn_57549060f/time-math-is-harder-than-it-looks-6-duration-bugs-and-how-to-avoid-them-3j8m</link>
      <guid>https://dev.to/anh_qunnguyn_57549060f/time-math-is-harder-than-it-looks-6-duration-bugs-and-how-to-avoid-them-3j8m</guid>
      <description>&lt;p&gt;Adding two durations sounds like first-grade math. &lt;code&gt;2:45&lt;/code&gt; plus &lt;code&gt;1:30&lt;/code&gt; — easy, right? Then a ticket comes in: a user logged &lt;code&gt;11:45 PM → 7:15 AM&lt;/code&gt; and your timesheet says they worked &lt;strong&gt;negative 16 hours&lt;/strong&gt;. Welcome to time arithmetic, where the obvious answer is usually wrong.&lt;/p&gt;

&lt;p&gt;Here are six duration bugs I keep seeing in code reviews, and the mental model that makes them go away.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Minutes are base-60, not base-100
&lt;/h3&gt;

&lt;p&gt;The number-one duration bug is treating &lt;code&gt;1:30&lt;/code&gt; as the decimal &lt;code&gt;1.30&lt;/code&gt;. It isn't — it's 1.5 hours. If you store time as &lt;code&gt;HH:MM&lt;/code&gt; strings and do arithmetic on the pieces without normalizing, you get garbage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WRONG: treats minutes like a decimal fraction&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.45&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 3.75 ❌ (you meant 2h45m + 1h30m)&lt;/span&gt;

&lt;span class="c1"&gt;// RIGHT: normalize to a single base unit first&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;toMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 255 minutes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "4:15" ✅&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule: &lt;strong&gt;convert everything to one base unit (seconds or minutes), do the math, convert back at the end.&lt;/strong&gt; When I just need to confirm a result by hand before trusting my code, I'll punch the two values into a &lt;a href="https://calculators.im/time-calculator" rel="noopener noreferrer"&gt;time calculator&lt;/a&gt; and check the output matches — faster than adding a &lt;code&gt;console.log&lt;/code&gt; and re-running.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Crossing midnight makes durations go negative
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;11:45 PM → 7:15 AM&lt;/code&gt; case. If end &amp;lt; start, the interval wrapped past midnight. Naive subtraction gives a negative number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;endMin&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startMin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// add a full day to unwrap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bites overnight shifts, sleep trackers, and anything spanning a day boundary. The fix is one line, but only if you remember the case exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. 12-hour vs 24-hour parsing
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;12:00 PM&lt;/code&gt; is noon. &lt;code&gt;12:00 AM&lt;/code&gt; is midnight. Almost every hand-rolled AM/PM parser gets the &lt;code&gt;12&lt;/code&gt; case backwards because the conversion isn't &lt;code&gt;+12 for PM&lt;/code&gt; — it's special-cased at 12.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;to24&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;period&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your product serves both US (12h) and most of Europe (24h), normalize on input and store 24h internally. Display formatting is a presentation concern — keep it out of your math layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. DST means a "day" isn't always 24 hours
&lt;/h3&gt;

&lt;p&gt;Twice a year, a calendar day is 23 or 25 hours long. If you compute durations by subtracting wall-clock times across a DST boundary, you'll be off by an hour. This is why you &lt;strong&gt;never&lt;/strong&gt; do duration math on local timestamps — convert to UTC (or epoch seconds) first, subtract, then format back to local for display.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-03-08T01:30:00-05:00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// before US spring-forward&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-03-08T03:30:00-04:00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;3.6e6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1 hour, not 2 — DST ate an hour&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For calendar-level differences (business days, age, date spans) the same UTC-first principle applies — our &lt;a href="https://calculators.im/date-calculator" rel="noopener noreferrer"&gt;date calculator&lt;/a&gt; handles that side, working in whole days rather than clock time.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Epoch math is your friend — until you mix units
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Date.now()&lt;/code&gt; returns &lt;strong&gt;milliseconds&lt;/strong&gt;. Unix &lt;code&gt;time()&lt;/code&gt; in most backends returns &lt;strong&gt;seconds&lt;/strong&gt;. Postgres &lt;code&gt;EXTRACT(EPOCH ...)&lt;/code&gt; returns seconds (as a float). Mix them and you're off by 1000×.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;            &lt;span class="c1"&gt;// 1780736400000&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1780736400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I'm debugging a raw epoch value and need to see it as a human time, I keep a &lt;a href="https://calculators.im/unix-timestamp-converter" rel="noopener noreferrer"&gt;unix timestamp converter&lt;/a&gt; open rather than mentally dividing by 1000 and squinting.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Decimal hours for payroll rounding
&lt;/h3&gt;

&lt;p&gt;Payroll systems want &lt;strong&gt;decimal hours&lt;/strong&gt; (8.25), not &lt;code&gt;8:15&lt;/code&gt;. The conversion is &lt;code&gt;minutes / 60&lt;/code&gt;, but rounding policy matters: some jurisdictions round to the nearest quarter-hour, some truncate. Decide the policy explicitly — don't let &lt;code&gt;toFixed(2)&lt;/code&gt; make it for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decimal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalMinutes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 8.25&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quarterRounded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decimal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// nearest 0.25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the conversion a &lt;a href="https://calculators.im/time-calculator" rel="noopener noreferrer"&gt;time calculator&lt;/a&gt; does when it shows both &lt;code&gt;HH:MM&lt;/code&gt; and decimal output — handy when you're reconciling a timesheet against what your code produced.&lt;/p&gt;




&lt;h3&gt;
  
  
  The one mental model
&lt;/h3&gt;

&lt;p&gt;Every one of these bugs comes from doing arithmetic in a representation that isn't additive. Wall-clock &lt;code&gt;HH:MM&lt;/code&gt; strings aren't additive (base-60, midnight wrap, DST). Local timestamps aren't additive (DST, timezones). &lt;strong&gt;Seconds-since-epoch are additive.&lt;/strong&gt; So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parse input into a single base unit (epoch seconds, or total minutes for clock durations).&lt;/li&gt;
&lt;li&gt;Do all math there.&lt;/li&gt;
&lt;li&gt;Format back to human representation only at the very end.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get that pipeline right and time math stops surprising you.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <item>
      <title>JSON Schema in 10 Minutes — Validation, Types &amp; Real Examples</title>
      <dc:creator>Anh Quân Nguyễn</dc:creator>
      <pubDate>Tue, 26 May 2026 03:44:14 +0000</pubDate>
      <link>https://dev.to/anh_qunnguyn_57549060f/json-schema-in-10-minutes-validation-types-real-examples-4pjg</link>
      <guid>https://dev.to/anh_qunnguyn_57549060f/json-schema-in-10-minutes-validation-types-real-examples-4pjg</guid>
      <description>&lt;p&gt;Two years ago I shipped a webhook handler without input validation. A partner started sending us a slightly malformed payload (an extra field, one missing required field) and our worker silently processed garbage into the database for three days before anyone noticed. By the time I traced it, we had 12,000 corrupt rows and a very awkward customer call.&lt;/p&gt;

&lt;p&gt;I learned JSON Schema the next week. This post is the cheat sheet I wish someone had handed me on day one — the keywords I actually use, the gotchas that bit me again later, and the honest comparison with OpenAPI and TypeScript types.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven types you'll use
&lt;/h2&gt;

&lt;p&gt;Every JSON value is one of seven types: &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;integer&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;object&lt;/code&gt;, &lt;code&gt;array&lt;/code&gt;, or &lt;code&gt;null&lt;/code&gt;. The &lt;code&gt;integer&lt;/code&gt; type is a JSON Schema convenience (raw JSON only has &lt;code&gt;number&lt;/code&gt;) but the schema layer enforces "no decimal places." A minimal schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That validates any string and rejects everything else. You can also accept a union:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful for optional fields you want to keep present in the payload rather than omitting. Before I write more than a one-line schema I usually paste a sample payload into a &lt;a href="https://calculators.im/json-formatter" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; to see the actual shape pretty-printed. Type errors almost always come from misreading the structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Objects, required, and the additionalProperties trap
&lt;/h2&gt;

&lt;p&gt;Most real validation work happens on objects. The three keywords you use every day are &lt;code&gt;properties&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt;, and &lt;code&gt;additionalProperties&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"age"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"boolean"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three gotchas to internalize. First, &lt;code&gt;properties&lt;/code&gt; describes each field but does NOT make any of them required. Without the &lt;code&gt;required&lt;/code&gt; array, every property is optional. Second, &lt;code&gt;required&lt;/code&gt; is a separate list of property names that must be present (presence only, you still need &lt;code&gt;type&lt;/code&gt; to validate the value). Third, &lt;code&gt;additionalProperties: false&lt;/code&gt; rejects any property not listed. Without this line, the schema accepts arbitrary extra fields silently. This was the bug that hit me — the partner was sending &lt;code&gt;email_address&lt;/code&gt; instead of &lt;code&gt;email&lt;/code&gt;, and without &lt;code&gt;additionalProperties: false&lt;/code&gt; my schema accepted it as "no email + an unknown field."&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;additionalProperties: false&lt;/code&gt; by default. Remove it only when you genuinely want a free-form object. For maps with arbitrary keys but a known value type, use it as a schema instead of a boolean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That validates any object where every value is a number. Perfect for price lookup tables, feature-flag percentages, or anything keyed dynamically.&lt;/p&gt;

&lt;h2&gt;
  
  
  String validation: minLength, pattern, format, enum
&lt;/h2&gt;

&lt;p&gt;Real string validation goes beyond "is it a string." The keywords that earn their keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;minLength&lt;/code&gt; / &lt;code&gt;maxLength&lt;/code&gt;, integer bounds on UTF-16 code units (not bytes, not graphemes)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pattern&lt;/code&gt;, ECMA-262 regex the string must match somewhere (use &lt;code&gt;^...$&lt;/code&gt; anchors for a full match)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format&lt;/code&gt;, named formats like &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;uri&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;date-time&lt;/code&gt;, &lt;code&gt;uuid&lt;/code&gt;, &lt;code&gt;ipv4&lt;/code&gt;, &lt;code&gt;ipv6&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enum&lt;/code&gt;, a fixed list of allowed values (works for any type)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;const&lt;/code&gt;, a single allowed value (equivalent to a one-item enum)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical username field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^[a-zA-Z0-9_]+$"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha that cost me a day: &lt;code&gt;format&lt;/code&gt; is informational by default in older drafts. You must enable format assertion in your validator. Ajv requires &lt;code&gt;ajv-formats&lt;/code&gt;. Python &lt;code&gt;jsonschema&lt;/code&gt; needs &lt;code&gt;format_checker&lt;/code&gt;. Without it, &lt;code&gt;"format": "email"&lt;/code&gt; documents intent but does not actually reject invalid emails. See the &lt;a href="https://json-schema.org/understanding-json-schema/reference/string#format" rel="noopener noreferrer"&gt;JSON Schema spec for format&lt;/a&gt; for the full list and the assertion behavior per draft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Number validation: minimum, maximum, multipleOf
&lt;/h2&gt;

&lt;p&gt;For numbers and integers, the validation keywords are arithmetic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;minimum&lt;/code&gt; / &lt;code&gt;maximum&lt;/code&gt;, inclusive bounds&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exclusiveMinimum&lt;/code&gt; / &lt;code&gt;exclusiveMaximum&lt;/code&gt;, exclusive bounds (in Draft 2020-12 these take a number, in older drafts they took a boolean)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;multipleOf&lt;/code&gt;, the value must be a multiple of this number&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Validating a percentage that must be 0 to 100 in 0.01 increments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"multipleOf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;multipleOf&lt;/code&gt; has a floating-point trap I keep getting wrong. 0.1 is not exactly representable in IEEE 754, so &lt;code&gt;{ "multipleOf": 0.1 }&lt;/code&gt; will sometimes reject values you expect to pass. For money, I now store and validate as integer cents (&lt;code&gt;{ "type": "integer", "minimum": 0 }&lt;/code&gt;). It is the same precision argument behind storing prices in the smallest currency unit everywhere else in the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Array validation: items, minItems, uniqueItems
&lt;/h2&gt;

&lt;p&gt;For arrays the workhorses are &lt;code&gt;items&lt;/code&gt; (schema applied to every element), &lt;code&gt;minItems&lt;/code&gt; / &lt;code&gt;maxItems&lt;/code&gt; (length bounds), and &lt;code&gt;uniqueItems&lt;/code&gt; (rejects duplicates by deep equality). A list of unique tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"uniqueItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For positional tuples where each index has a different schema, use &lt;code&gt;prefixItems&lt;/code&gt; in Draft 2020-12 or &lt;code&gt;items&lt;/code&gt; as an array in older drafts. A coordinate pair where index 0 is longitude and index 1 is latitude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prefixItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mi"&gt;-90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trailing &lt;code&gt;"items": false&lt;/code&gt; rejects any extra elements beyond the two declared positions. The array equivalent of &lt;code&gt;additionalProperties: false&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema composition: $ref, allOf, oneOf, anyOf
&lt;/h2&gt;

&lt;p&gt;Once your schemas grow past a single page, you will want to break them up and combine them. JSON Schema has four composition keywords:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$ref&lt;/code&gt;, reuse another schema by JSON Pointer (e.g., &lt;code&gt;"#/$defs/address"&lt;/code&gt; or an external URL)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;allOf&lt;/code&gt;, data must validate against every subschema (intersection / mixin)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;anyOf&lt;/code&gt;, data must validate against at least one (union, OK if multiple match)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;oneOf&lt;/code&gt;, data must validate against exactly one (XOR, rejects if zero or multiple match)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A reusable address schema referenced from two parents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$defs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"street"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"street"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"shipping"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#/$defs/address"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#/$defs/address"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For discriminated unions (event types, message kinds), &lt;code&gt;oneOf&lt;/code&gt; with a &lt;code&gt;const&lt;/code&gt; discriminator is the standard pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"oneOf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"const"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"const"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sms"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;+[1-9]&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;d{1,14}$"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A real signup schema
&lt;/h2&gt;

&lt;p&gt;Putting every keyword together, here is roughly the schema I now use for a signup endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://json-schema.org/draft/2020-12/schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SignupRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;254&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^[a-zA-Z0-9_]{3,20}$"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"age"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AU"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"newsletter"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"boolean"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"referrals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                   &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uniqueItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"age"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It enforces the email format with the 254-char maximum from RFC 5321, a 12-character minimum password from NIST SP 800-63B, a regex-validated username, an integer age within plausible bounds, a closed enum of supported countries, an optional boolean with a documented default, and an optional referral list capped at 5 unique emails. The trailing &lt;code&gt;additionalProperties: false&lt;/code&gt; is the line that would have saved me three days and 12,000 rows two years ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooling: Ajv (Node) and jsonschema (Python)
&lt;/h2&gt;

&lt;p&gt;Declare which draft you target with the &lt;code&gt;$schema&lt;/code&gt; keyword at the root. The two production-grade validators I reach for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ajv.js.org/" rel="noopener noreferrer"&gt;Ajv&lt;/a&gt; for Node.js and browser&lt;/strong&gt;, the fastest JS validator, supports Draft 2020-12. Install &lt;code&gt;ajv&lt;/code&gt; and &lt;code&gt;ajv-formats&lt;/code&gt; together if you use &lt;code&gt;format&lt;/code&gt;. Compile schemas once at startup with &lt;code&gt;const validate = ajv.compile(schema)&lt;/code&gt;, then call &lt;code&gt;validate(data)&lt;/code&gt; on every request. This is 10 to 100 times faster than recompiling per call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;jsonschema&lt;/code&gt; for Python&lt;/strong&gt;, the reference Python validator. Use &lt;code&gt;Draft202012Validator(schema).validate(data)&lt;/code&gt; or iterate &lt;code&gt;.iter_errors(data)&lt;/code&gt; to surface all errors at once instead of failing on the first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For quick iteration without writing code, I usually paste the schema and a sample payload into a &lt;a href="https://calculators.im/json-formatter" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; to confirm both parse, then run them through a browser-based validator. When debugging an unexpected failure, a &lt;a href="https://calculators.im/diff-checker" rel="noopener noreferrer"&gt;diff checker&lt;/a&gt; helps me compare a failing payload against a known-good payload to spot the offending field.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON Schema vs OpenAPI vs TypeScript
&lt;/h2&gt;

&lt;p&gt;These three describe data shapes but solve different problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript types&lt;/strong&gt; are compile-time only. They vanish at runtime, so a malformed API payload will silently corrupt your program if you trust the type without validating. Great for developer ergonomics, useless for runtime safety.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON Schema&lt;/strong&gt; is runtime validation that works in any language. Use it at API boundaries, for config files, for database documents, and for any cross-language data contract. A single schema can drive validation in your Node frontend, Python backend, and Go worker without rewriting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAPI&lt;/strong&gt; (formerly Swagger) wraps JSON Schema inside an API description. It adds endpoints, methods, status codes, authentication, examples, and tooling for client SDK generation. Use it when you are describing an HTTP API and want documentation, client codegen, and validation in one document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The stack I default to now: write the JSON Schema as source of truth, generate TypeScript types from it with &lt;code&gt;json-schema-to-typescript&lt;/code&gt;, and embed the same schema inside an OpenAPI spec for HTTP routes. One source, three outputs, no drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes I kept making
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Forgetting &lt;code&gt;additionalProperties: false&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The original bug. Without it, any extra field passes validation. A client typo like &lt;code&gt;{ "emial": "x@y.com" }&lt;/code&gt; validates as "no email present plus an unknown field" instead of the clean error you want. Add it by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Confusing &lt;code&gt;required&lt;/code&gt; with &lt;code&gt;type&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Listing a property under &lt;code&gt;properties&lt;/code&gt; does NOT make it required. You must also add it to the &lt;code&gt;required&lt;/code&gt; array. Conversely, &lt;code&gt;required&lt;/code&gt; only checks presence. A wrong-type field still fails, but on the type check, not the required check.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Using &lt;code&gt;format&lt;/code&gt; without enabling assertion
&lt;/h3&gt;

&lt;p&gt;In Ajv you must &lt;code&gt;require('ajv-formats')(ajv)&lt;/code&gt;. In Python &lt;code&gt;jsonschema&lt;/code&gt; pass &lt;code&gt;format_checker=FormatChecker()&lt;/code&gt;. Without this, &lt;code&gt;format: email&lt;/code&gt; is metadata only and accepts any string. I burned half a day on this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;oneOf&lt;/code&gt; where &lt;code&gt;anyOf&lt;/code&gt; is correct
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;oneOf&lt;/code&gt; rejects data that matches more than one subschema. If your subschemas overlap (a value that is both a positive integer and a multiple of 5), &lt;code&gt;oneOf&lt;/code&gt; rejects. Use it only for genuinely disjoint cases like discriminated unions.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;code&gt;multipleOf&lt;/code&gt; with floats
&lt;/h3&gt;

&lt;p&gt;IEEE 754 cannot exactly represent 0.1. &lt;code&gt;{ "multipleOf": 0.1 }&lt;/code&gt; will reject values you expect to pass. Use integer units (cents, basis points) instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Recompiling schemas on every request
&lt;/h3&gt;

&lt;p&gt;Ajv's &lt;code&gt;compile()&lt;/code&gt; is expensive. The compiled validator is fast. Compile once at module load, store the function, reuse it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;JSON Schema looks verbose at first. Often the schema is longer than the data. That is the point. Every constraint you encode is one bug you cannot ship. Start with your top three API endpoints, then your config files, then your cross-service messages. Within a sprint you will catch at least one bug that would have made it to production.&lt;/p&gt;

&lt;p&gt;If you want a sandbox, try the &lt;a href="https://json-schema.org/learn" rel="noopener noreferrer"&gt;JSON Schema Reference Tutorial&lt;/a&gt; and an online validator like jsonschemavalidator.net. And if you ever debug a &lt;code&gt;pattern&lt;/code&gt; validation that is misbehaving, a &lt;a href="https://calculators.im/regex-tester" rel="noopener noreferrer"&gt;regex tester&lt;/a&gt; is faster than guessing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://calculators.im/blog/json-schema-10-minutes-validation-types-ajv-examples-guide" rel="noopener noreferrer"&gt;calculators.im&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>json</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Keep Forgetting Subnet Math — Here's the Cheat Sheet I Actually Use</title>
      <dc:creator>Anh Quân Nguyễn</dc:creator>
      <pubDate>Tue, 19 May 2026 03:17:29 +0000</pubDate>
      <link>https://dev.to/anh_qunnguyn_57549060f/i-keep-forgetting-subnet-math-heres-the-cheat-sheet-i-actually-use-37o7</link>
      <guid>https://dev.to/anh_qunnguyn_57549060f/i-keep-forgetting-subnet-math-heres-the-cheat-sheet-i-actually-use-37o7</guid>
      <description>&lt;p&gt;I have been writing backend code for years and I still cannot tell you, off the top of my head, how many usable hosts are in a &lt;code&gt;/22&lt;/code&gt;. Every time I open an AWS VPC config or a Kubernetes cluster blueprint, I do the same Google search: "subnet calculator", click whatever ranks first, type my CIDR, write down the answer. Then I forget all of it within a week.&lt;/p&gt;

&lt;p&gt;After the fourth or fifth time I had to look up whether &lt;code&gt;/27&lt;/code&gt; gives me 30 or 32 usable IPs, I sat down and built a one-page cheat sheet for myself. This post is that cheat sheet, plus the few mental shortcuts that have actually stuck. If you ship to cloud and only touch subnet math a few times a year, this is the post I wish I had bookmarked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three numbers that matter
&lt;/h2&gt;

&lt;p&gt;For any CIDR, only three numbers matter in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Block size&lt;/strong&gt; — how many IPs total. Formula: &lt;code&gt;2^(32 − prefix)&lt;/code&gt;. A &lt;code&gt;/24&lt;/code&gt; has 256 addresses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usable hosts&lt;/strong&gt; — block size minus 2 (one for the network address, one for the broadcast). A &lt;code&gt;/24&lt;/code&gt; has 254 usable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Magic number&lt;/strong&gt; — &lt;code&gt;256 − mask_octet&lt;/code&gt;. Tells you where the next subnet starts. For &lt;code&gt;/26&lt;/code&gt; (mask &lt;code&gt;255.255.255.192&lt;/code&gt;), the magic number is &lt;code&gt;64&lt;/code&gt;, so subnets land at &lt;code&gt;.0&lt;/code&gt;, &lt;code&gt;.64&lt;/code&gt;, &lt;code&gt;.128&lt;/code&gt;, &lt;code&gt;.192&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Memorize one table and you will never need to do binary math in your head again:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Block size&lt;/th&gt;
&lt;th&gt;Usable hosts&lt;/th&gt;
&lt;th&gt;Mask&lt;/th&gt;
&lt;th&gt;Magic&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/22&lt;/td&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;td&gt;1,022&lt;/td&gt;
&lt;td&gt;255.255.252.0&lt;/td&gt;
&lt;td&gt;4 (3rd octet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/23&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;510&lt;/td&gt;
&lt;td&gt;255.255.254.0&lt;/td&gt;
&lt;td&gt;2 (3rd octet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/24&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;254&lt;/td&gt;
&lt;td&gt;255.255.255.0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/25&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;255.255.255.128&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/26&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;255.255.255.192&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/27&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;255.255.255.224&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/28&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;255.255.255.240&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/29&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;255.255.255.248&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/30&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;255.255.255.252&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the entire subnet math for 99% of cloud work. Print it, tape it to your monitor, move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The magic number trick, with a worked example
&lt;/h2&gt;

&lt;p&gt;Suppose someone gives you the IP &lt;code&gt;10.20.30.45/26&lt;/code&gt; and asks for the network and broadcast addresses. No calculator, no binary conversion.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prefix is &lt;code&gt;/26&lt;/code&gt;, so the magic number is &lt;code&gt;64&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Look at the last octet: &lt;code&gt;45&lt;/code&gt;. Which multiple of 64 does it fall into? &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;64&lt;/code&gt;, &lt;code&gt;128&lt;/code&gt;, &lt;code&gt;192&lt;/code&gt;. &lt;code&gt;45&lt;/code&gt; is in the &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;63&lt;/code&gt; block.&lt;/li&gt;
&lt;li&gt;Network address: &lt;code&gt;10.20.30.0&lt;/code&gt;. Broadcast: &lt;code&gt;10.20.30.63&lt;/code&gt; (one less than the next subnet, which would start at &lt;code&gt;.64&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Usable range: &lt;code&gt;10.20.30.1&lt;/code&gt; to &lt;code&gt;10.20.30.62&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same trick for prefixes that fall in the third octet. &lt;code&gt;10.20.30.45/22&lt;/code&gt; → magic number is &lt;code&gt;4&lt;/code&gt;, applied to the third octet. &lt;code&gt;30&lt;/code&gt; falls in the &lt;code&gt;28&lt;/code&gt;–&lt;code&gt;31&lt;/code&gt; block (because &lt;code&gt;28&lt;/code&gt; is the closest multiple of 4 at or below &lt;code&gt;30&lt;/code&gt;). Network: &lt;code&gt;10.20.28.0&lt;/code&gt;. Broadcast: &lt;code&gt;10.20.31.255&lt;/code&gt;. Block spans four &lt;code&gt;/24&lt;/code&gt;s.&lt;/p&gt;

&lt;p&gt;If your subnet boundaries do not look "round" — say &lt;code&gt;10.20.30.0/22&lt;/code&gt; — that is a malformed CIDR. The network address must align to the magic number. &lt;code&gt;10.20.30.0&lt;/code&gt; is not a valid &lt;code&gt;/22&lt;/code&gt; start; &lt;code&gt;10.20.28.0/22&lt;/code&gt; is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloud-specific gotchas you will hit
&lt;/h2&gt;

&lt;p&gt;The textbook subnet math is the easy part. What actually trips up cloud engineers is the platform-specific quirks layered on top.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS VPC
&lt;/h3&gt;

&lt;p&gt;AWS reserves &lt;strong&gt;five&lt;/strong&gt; addresses per subnet, not two. The network address, the broadcast, and three more: the VPC router, DNS, and a future-use address. So a &lt;code&gt;/28&lt;/code&gt; AWS subnet has 11 usable IPs, not 14. This catches people every time they try to fit "exactly 14 EC2 instances" into a &lt;code&gt;/28&lt;/code&gt; and run out of IPs at instance #12.&lt;/p&gt;

&lt;p&gt;VPC CIDR sizing also has hard limits: minimum &lt;code&gt;/28&lt;/code&gt;, maximum &lt;code&gt;/16&lt;/code&gt;. You can attach secondary CIDR blocks, but you cannot ever shrink a primary CIDR after creation. &lt;strong&gt;Always size up.&lt;/strong&gt; A &lt;code&gt;/16&lt;/code&gt; gives you 65,536 addresses for free; there is no cost penalty for picking a large VPC CIDR and no benefit to picking a tight one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes
&lt;/h3&gt;

&lt;p&gt;A typical EKS or GKE cluster needs three separate CIDRs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node CIDR&lt;/strong&gt; — a subnet of the VPC, one IP per node (plus AWS's five). Size for max-nodes × 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pod CIDR&lt;/strong&gt; — usually &lt;code&gt;10.244.0.0/16&lt;/code&gt; (Flannel default) or &lt;code&gt;192.168.0.0/16&lt;/code&gt; (Calico). Each node gets a &lt;code&gt;/24&lt;/code&gt; slice (Flannel) so the cluster maxes out at 254 nodes with a &lt;code&gt;/16&lt;/code&gt; pod CIDR. Larger clusters need &lt;code&gt;/12&lt;/code&gt; or smaller per-node allocations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service CIDR&lt;/strong&gt; — usually &lt;code&gt;10.96.0.0/12&lt;/code&gt; (kubeadm default). Sized for total Services, not Pods. A &lt;code&gt;/16&lt;/code&gt; is overkill for most clusters; a &lt;code&gt;/20&lt;/code&gt; is plenty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most common k8s networking mistake is overlapping the pod CIDR with the VPC CIDR. Pick non-overlapping ranges from day one — renumbering pod CIDR in a running cluster is a recreate-the-cluster operation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker
&lt;/h3&gt;

&lt;p&gt;Docker's default bridge is &lt;code&gt;172.17.0.0/16&lt;/code&gt;. The default &lt;code&gt;docker network create&lt;/code&gt; allocates from &lt;code&gt;172.18.0.0/16&lt;/code&gt; upward in &lt;code&gt;/24&lt;/code&gt; chunks. If your corporate VPN also uses &lt;code&gt;172.x.x.x&lt;/code&gt; ranges (very common), you will get phantom routing failures where Docker containers can reach the internet but cannot reach internal services. Reconfigure Docker's default address pools in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"default-address-pools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.99.0.0/16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This carves Docker out of the &lt;code&gt;172.x&lt;/code&gt; space and into a private &lt;code&gt;10.x&lt;/code&gt; range your VPN almost certainly does not use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subnet math in code (Python and Go)
&lt;/h2&gt;

&lt;p&gt;When you actually need to compute subnets programmatically — generating Terraform, validating user input, planning a migration — every modern language has a stdlib for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python&lt;/strong&gt; uses &lt;code&gt;ipaddress&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;

&lt;span class="c1"&gt;# Parse a network
&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_network&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.20.30.0/22&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;network_address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# 10.20.28.0 — auto-normalized!
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;broadcast_address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 10.20.31.255
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_addresses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# 1024
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hosts&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt; &lt;span class="c1"&gt;# 1022 — excludes network + broadcast
&lt;/span&gt;
&lt;span class="c1"&gt;# Check if an IP is in a subnet
&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.20.29.50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# True
&lt;/span&gt;
&lt;span class="c1"&gt;# Subdivide a /22 into /24s
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subnets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# 10.20.28.0/24
# 10.20.29.0/24
# 10.20.30.0/24
# 10.20.31.0/24
&lt;/span&gt;
&lt;span class="c1"&gt;# Detect overlap (catches the VLSM mistake from earlier)
&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_network&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.0/25&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_network&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.64/26&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;overlaps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: passing &lt;code&gt;strict=False&lt;/code&gt; to &lt;code&gt;ip_network&lt;/code&gt; lets you parse &lt;code&gt;10.20.30.0/22&lt;/code&gt; (a malformed CIDR — &lt;code&gt;.30&lt;/code&gt; is not the network address). With &lt;code&gt;strict=True&lt;/code&gt; (the default since Python 3.9) it raises &lt;code&gt;ValueError&lt;/code&gt;. Use &lt;code&gt;strict=True&lt;/code&gt; in production input validation; the explicit error is far more useful than a silently-corrected network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go&lt;/strong&gt; uses &lt;code&gt;net/netip&lt;/code&gt; (Go 1.18+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"net/netip"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;netip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParsePrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"10.20.30.0/22"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// Normalize to actual network address&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Masked&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Addr&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c"&gt;// 10.20.28.0&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bits&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c"&gt;// 22&lt;/span&gt;

    &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;netip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseAddr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"10.20.29.50"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c"&gt;// true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The older &lt;code&gt;net.IPNet&lt;/code&gt; API still works but &lt;code&gt;net/netip&lt;/code&gt; is value-typed, allocation-free, and the recommended choice for new code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 30-second AWS CLI sanity check
&lt;/h2&gt;

&lt;p&gt;When debugging "why can't my EC2 instance reach this other EC2 instance," step one is always: are they on subnets that route to each other? Quick CLI check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List subnets with their CIDRs in the current VPC&lt;/span&gt;
aws ec2 describe-subnets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Subnets[*].[SubnetId,CidrBlock,AvailabilityZone]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="c"&gt;# Check route table for a subnet&lt;/span&gt;
aws ec2 describe-route-tables &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;association.subnet-id,Values&lt;span class="o"&gt;=&lt;/span&gt;subnet-abc123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'RouteTables[*].Routes'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If two subnets are in the same VPC and the route tables both have &lt;code&gt;local&lt;/code&gt; routes for the VPC CIDR, they can reach each other (assuming security groups and NACLs allow). Most "why no connectivity" tickets resolve here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three mistakes I have personally shipped to production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Subnet too small.&lt;/strong&gt; A &lt;code&gt;/28&lt;/code&gt; for "we only need 10 instances" — then AWS takes 5, leaving 11. Add a load balancer, add scaling, you are out of IPs. Always start at &lt;code&gt;/24&lt;/code&gt; for any production subnet unless you have a hard reason not to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pod CIDR overlapping VPC CIDR.&lt;/strong&gt; Created a GKE cluster with default pod CIDR &lt;code&gt;10.0.0.0/14&lt;/code&gt; inside a VPC at &lt;code&gt;10.0.0.0/16&lt;/code&gt;. Cluster came up, pods on the same node could reach each other, pods on different nodes silently dropped traffic. Three hours debugging later: pick non-overlapping CIDRs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgot AWS's 5 reserved IPs.&lt;/strong&gt; Provisioned 14 EC2 instances in a fresh &lt;code&gt;/28&lt;/code&gt;. First 11 launched fine, the rest failed with &lt;code&gt;InsufficientFreeAddressesInSubnet&lt;/code&gt;. The fix is &lt;code&gt;/27&lt;/code&gt; (32 addresses, 27 usable after AWS reservations).&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — make subnet math boring
&lt;/h2&gt;

&lt;p&gt;The whole point of the cheat sheet is that subnet math should be boring. You should not have to think about it. You should be able to glance at a CIDR, know the block size and the magic number, and move on to whatever interesting problem you were actually trying to solve.&lt;/p&gt;

&lt;p&gt;If you need to verify a non-trivial CIDR or plan a VLSM allocation across multiple subnets, I built a free &lt;a href="https://calculators.im/subnet-calculator" rel="noopener noreferrer"&gt;subnet calculator&lt;/a&gt; that handles IPv4, IPv6, and VLSM planning. There's also a &lt;a href="https://calculators.im/blog/subnet-calculator-guide-cidr-vlsm-ipv4-ipv6-network-math" rel="noopener noreferrer"&gt;longer-form guide on the math behind CIDR, VLSM, and IPv6 subnetting&lt;/a&gt; if you want to go deeper than this cheat sheet.&lt;/p&gt;

&lt;p&gt;The tool is free, no signup, no tracking. If it saves you a Google search next quarter, that is the entire goal.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Cross-posted with canonical from &lt;a href="https://calculators.im/blog/subnet-calculator-guide-cidr-vlsm-ipv4-ipv6-network-math" rel="noopener noreferrer"&gt;calculators.im&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
      <category>devops</category>
      <category>aws</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Lessons Learned Building 270+ Online Calculators: A Developer's Deep Dive</title>
      <dc:creator>Anh Quân Nguyễn</dc:creator>
      <pubDate>Tue, 21 Apr 2026 10:26:03 +0000</pubDate>
      <link>https://dev.to/anh_qunnguyn_57549060f/lessons-learned-building-270-online-calculators-a-developers-deep-dive-3nhf</link>
      <guid>https://dev.to/anh_qunnguyn_57549060f/lessons-learned-building-270-online-calculators-a-developers-deep-dive-3nhf</guid>
      <description>&lt;p&gt;When I started building &lt;a href="https://calculators.im" rel="noopener noreferrer"&gt;calculators.im&lt;/a&gt;, I thought it would be a simple project — create a few calculator pages, deploy, done. Two years and 270+ calculators later, I've learned more about web performance, SEO, and product scaling than I ever expected. Here's everything I wish someone had told me before I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Project: What Is calculators.im?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://calculators.im" rel="noopener noreferrer"&gt;calculators.im&lt;/a&gt; is a collection of 270+ free online calculators covering everything from finance (mortgage, compound interest, ROI) to health (BMI, calorie needs, pregnancy due date) to math (scientific, percentage, fractions) and beyond.&lt;/p&gt;

&lt;p&gt;The tech stack: &lt;strong&gt;Go&lt;/strong&gt; for the backend, &lt;strong&gt;HTMX&lt;/strong&gt; for interactivity, &lt;strong&gt;Tailwind CSS&lt;/strong&gt; for styling, &lt;strong&gt;Redis&lt;/strong&gt; for caching, and &lt;strong&gt;NGINX + Cloudflare&lt;/strong&gt; for serving.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 1: SEO Is 80% of Your Traffic — Treat It as a Feature
&lt;/h2&gt;

&lt;p&gt;I originally thought "build good calculators and they'll come." Wrong.&lt;/p&gt;

&lt;p&gt;The reality: calculator sites are brutally competitive. There are dozens of sites competing for every keyword like "mortgage calculator" or "BMI calculator." Here's what actually works:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target long-tail keywords over short ones.&lt;/strong&gt; Instead of competing for "loan calculator" (dominated by Bankrate, NerdWallet), target "car loan calculator with extra payments" or "loan calculator with balloon payment." Lower competition, still significant volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema markup is non-negotiable.&lt;/strong&gt; Adding &lt;code&gt;WebApplication&lt;/code&gt; and &lt;code&gt;FAQPage&lt;/code&gt; schema to each calculator dramatically increased click-through rates. Google often shows rich results in SERPs for tool pages with proper schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page speed = ranking factor for tools.&lt;/strong&gt; Users expect calculators to respond instantly. Every 100ms of latency costs you. With Go + HTMX, our Time to First Byte (TTFB) is under 50ms globally thanks to Cloudflare caching.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 2: HTMX Was the Right Bet — But Has Trade-offs
&lt;/h2&gt;

&lt;p&gt;Choosing HTMX over React was controversial when I started. Here's the honest verdict after building 270 pages:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wins:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero client-side hydration = better Core Web Vitals (LCP, CLS)&lt;/li&gt;
&lt;li&gt;SEO-friendly by default — all content is server-rendered&lt;/li&gt;
&lt;li&gt;Bundle size: ~14kb for htmx vs 130kb+ for a React app&lt;/li&gt;
&lt;li&gt;Simpler mental model: HTML attributes over JavaScript state management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complex interactions (real-time charts, drag-and-drop) require either Hyperscript or vanilla JS alongside HTMX&lt;/li&gt;
&lt;li&gt;The community is smaller than React's, so fewer StackOverflow answers&lt;/li&gt;
&lt;li&gt;Some HTMX patterns feel verbose for highly dynamic UIs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Would I choose HTMX again?&lt;/strong&gt; Yes — especially for content-heavy tool sites where SEO matters. For a complex SaaS dashboard, I'd reconsider.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 3: Go Is Overkill for Small Projects — Perfect for Scale
&lt;/h2&gt;

&lt;p&gt;Go was genuinely the right choice, but not for the reasons I expected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory footprint:&lt;/strong&gt; Our entire server runs on a $6/month VPS and handles thousands of daily visits with ~30MB RAM usage. A Node.js equivalent would use 5-10x more memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compilation catches bugs early.&lt;/strong&gt; Go's strict type system eliminated an entire category of runtime errors that plague dynamic-language projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single binary deployment.&lt;/strong&gt; &lt;code&gt;go build&lt;/code&gt;, rsync to server, restart. No npm install, no dependency conflicts, no environment issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The unexpected lesson:&lt;/strong&gt; Go forces you to be explicit about error handling. At first this feels tedious (&lt;code&gt;if err != nil&lt;/code&gt; everywhere), but it makes your code dramatically more reliable in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 4: Calculator UX Is Harder Than It Looks
&lt;/h2&gt;

&lt;p&gt;Building a "simple" calculator that delights users involves surprisingly subtle decisions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input formatting:&lt;/strong&gt; Users expect numbers to auto-format (1000 → 1,000) as they type. Implementing this with HTMX + Go required careful input sanitization to avoid formatting conflicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instant results:&lt;/strong&gt; Never make users click a "Calculate" button. Use HTMX's &lt;code&gt;hx-trigger="input"&lt;/code&gt; to recalculate on every keystroke. Users expect calculator-app behavior from web calculators.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mobile-first number inputs:&lt;/strong&gt; On mobile, use &lt;code&gt;inputmode="decimal"&lt;/code&gt; rather than &lt;code&gt;type="number"&lt;/code&gt;. The decimal keyboard is much better than the native number input's spinner interface for financial inputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error states matter:&lt;/strong&gt; When a user enters invalid input (letters in a number field, negative age, etc.), show clear, friendly error messages in context — not a page-level alert.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 5: The Content Around the Calculator Matters More Than the Calculator
&lt;/h2&gt;

&lt;p&gt;This was my biggest revelation: the page content surrounding the calculator is more important for SEO than the calculator itself.&lt;/p&gt;

&lt;p&gt;Every calculator page on &lt;a href="https://calculators.im" rel="noopener noreferrer"&gt;calculators.im&lt;/a&gt; includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 500-800 word explanation of the formula and methodology&lt;/li&gt;
&lt;li&gt;A worked example with real numbers&lt;/li&gt;
&lt;li&gt;An FAQ section targeting "People Also Ask" queries&lt;/li&gt;
&lt;li&gt;A table of common values for reference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This content strategy is what earns backlinks, ranks for informational queries, and keeps users on the page longer — all positive signals.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 6: Redis Caching Saved Us at Scale
&lt;/h2&gt;

&lt;p&gt;When a Reddit post linked to our &lt;a href="https://calculators.im/percentage-calculator" rel="noopener noreferrer"&gt;percentage calculator&lt;/a&gt;, we got ~8,000 visits in 2 hours. Without Redis caching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every request would hit Go and render the page fresh&lt;/li&gt;
&lt;li&gt;At 8,000 req/hr, that's manageable but wasteful&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With Redis:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Static page HTML is cached for 1 hour&lt;/li&gt;
&lt;li&gt;Dynamic calculation results are cached by input parameters&lt;/li&gt;
&lt;li&gt;Cache hit rate during the spike: 94%&lt;/li&gt;
&lt;li&gt;P99 response time during the spike: 18ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The caching strategy: cache at two layers — page-level (full HTML response via Cloudflare) and calculation-level (Redis for computed results with the same inputs).&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 7: Build the Infrastructure Once, Deploy 270 Times
&lt;/h2&gt;

&lt;p&gt;The most important architectural decision was treating calculators as data, not code.&lt;/p&gt;

&lt;p&gt;Each calculator is defined by a YAML config file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Input fields with types, labels, validation rules&lt;/li&gt;
&lt;li&gt;Formula as a Go expression&lt;/li&gt;
&lt;li&gt;Output formatting&lt;/li&gt;
&lt;li&gt;SEO metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Go backend reads these configs and generates both the HTML and the calculation logic dynamically. Adding a new calculator takes about 15 minutes — write the YAML, write the formula, deploy.&lt;/p&gt;

&lt;p&gt;This approach let us go from 10 calculators to 270+ without a linear increase in development time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with the content strategy, not the calculator.&lt;/strong&gt; Write the explanatory content first, then build the tool around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add structured data from day 1.&lt;/strong&gt; Retrofitting schema markup to 270 pages was painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build analytics from the start.&lt;/strong&gt; Understanding which calculators get used heavily vs. which ones are ignored shapes your roadmap.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Stack in Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Go — fast, cheap to run, rock-solid&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend interactivity:&lt;/strong&gt; HTMX — SEO-friendly, tiny footprint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Styling:&lt;/strong&gt; Tailwind CSS — consistent, maintainable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching:&lt;/strong&gt; Redis — essential at any meaningful scale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure:&lt;/strong&gt; NGINX + Cloudflare — global performance, free DDoS protection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building a similar tool site, I hope this saves you some of the trial and error. Check out &lt;a href="https://calculators.im" rel="noopener noreferrer"&gt;calculators.im&lt;/a&gt; to see what 270 calculators looks like in practice.&lt;/p&gt;

&lt;p&gt;What questions do you have? Drop them in the comments — happy to go deeper on any of these lessons.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>go</category>
      <category>seo</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
