<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged database</title>
<link>https://coderlegion.com/tag/database</link>
<description>Powered by Question2Answer</description>
<item>
<title>Flyway Migration: From Legacy Database to CI/CD</title>
<link>https://coderlegion.com/32961/flyway-migration-from-legacy-database-to-ci-cd</link>
<description>&lt;p&gt;Most teams think of Flyway only when they need to manage ongoing database schema changes. But what if your project already has a complete, stable schema and you’re only now introducing version control? Or what if you want to incorporate your schema into a CI/CD pipeline for reliable, reproducible deployments?&lt;/p&gt;
&lt;p&gt;In this guide, I’ll walk you through a real-world scenario where we used Flyway to baseline an existing MySQL database and then extended it to support version-controlled schema migrations in CI/CD, without disrupting production.&lt;/p&gt;
&lt;h3 id=&quot;what-is-flyway-&quot;&gt;What is Flyway?&lt;/h3&gt;
&lt;p&gt;Flyway is a simple but powerful tool for managing database migrations. It applies versioned SQL scripts to your database and tracks them in a special table called &lt;code&gt;flyway_schema_history&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Typical script names:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;V1__initial_schema.sql
V2__add_orders_table.sql
V3__rename_column.sql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flyway ensures these scripts run only once and in the correct order. It also integrates well with CI/CD tools, making it ideal for automated environments.&lt;/p&gt;
&lt;h3 id=&quot;use-case-existing-schema-with-no-migrations-yet-&quot;&gt;Use Case: Existing Schema with No Migrations (Yet)&lt;/h3&gt;
&lt;p&gt;In our project, we had:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A production MySQL database with dozens of tables&lt;/li&gt;
&lt;li&gt;No migration history&lt;/li&gt;
&lt;li&gt;No current need for structural changes&lt;/li&gt;
&lt;li&gt;A desire to version control the database and enable future migrations via CI/CD&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;step-1-baselining-an-existing-schema&quot;&gt;Step 1: Baselining an Existing Schema&lt;/h3&gt;
&lt;h4 id=&quot;dump-the-schema&quot;&gt;Dump the schema&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mysqldump -u root -p --no-data your_database &amp;gt; V1__baseline_schema.sql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Clean the output: remove auto-increments, user references, and environment-specific SQL.&lt;/p&gt;
&lt;h4 id=&quot;create-directory-structure&quot;&gt;Create directory structure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p db/sql
mv V1__baseline_schema.sql db/sql/
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;configure-flyway-conf&quot;&gt;Configure flyway.conf&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;flyway.url=jdbc:mysql://your-db-host:3306/your_database
flyway.user=db_user
flyway.password=secret_password
flyway.locations=filesystem:db/sql
flyway.baselineOnMigrate=true
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;run-baseline&quot;&gt;Run baseline&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flyway baseline
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates the &lt;code&gt;flyway_schema_history&lt;/code&gt; table and marks version 1 as the current state without running any script.&lt;/p&gt;
&lt;h3 id=&quot;step-2-add-a-real-migration&quot;&gt;Step 2: Add a Real Migration&lt;/h3&gt;
&lt;p&gt;After baselining, we added a real change in a new script&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- db/sql/V2__add_status_to_orders.sql
ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we ran:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flyway migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Flyway applied version 2, recorded it in history, and our database was now safely versioned.&lt;/p&gt;
&lt;h3 id=&quot;-step-3-integrate-flyway-into-ci-cd&quot;&gt;⚙️ Step 3: Integrate Flyway into CI/CD&lt;/h3&gt;
&lt;p&gt;You can integrate Flyway with GitHub Actions, GitLab CI, Bitbucket Pipelines, or Jenkins.&lt;/p&gt;
&lt;h3 id=&quot;-example-github-actions-workflow&quot;&gt;✅ Example GitHub Actions Workflow&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Flyway Migration
on:
  push:
    branches: [ main ]

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v3
      - name: Set up Java (Flyway needs it)
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'
      - name: Install Flyway
        run: |
          curl -L https://repo1.maven.org/maven2/org/flywaydb/flyway-commandline/10.8.1/flyway-commandline-10.8.1-linux-x64.tar.gz | tar xvz
          sudo ln -s $PWD/flyway-10.8.1/flyway /usr/local/bin/flyway
      - name: Run Flyway Migrations
        env:
          FLYWAY_URL: jdbc:mysql://your-host:3306/your_database
          FLYWAY_USER: ${{ secrets.DB_USER }}
          FLYWAY_PASSWORD: ${{ secrets.DB_PASSWORD }}
        run: |
          flyway -url=$FLYWAY_URL \
                 -user=$FLYWAY_USER \
                 -password=$FLYWAY_PASSWORD \
                 -locations=filesystem:./db/sql \
                 migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;benefits-of-this-approach&quot;&gt;Benefits of This Approach&lt;/h3&gt;
&lt;p&gt;✅ You gain &lt;strong&gt;auditability&lt;/strong&gt; for all database changes&lt;/p&gt;
&lt;p&gt;✅ Developers can build &lt;strong&gt;local environments&lt;/strong&gt; using the same scripts&lt;/p&gt;
&lt;p&gt;✅ CI/CD ensures &lt;strong&gt;consistency&lt;/strong&gt; across all environments&lt;/p&gt;
&lt;p&gt;✅ Flyway’s &lt;code&gt;validate&lt;/code&gt; command catches missing or changed scripts&lt;/p&gt;
&lt;p&gt;✅ Schema management becomes part of your &lt;strong&gt;software delivery lifecycle&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;potential-challenges&quot;&gt;Potential Challenges&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Problem → Fix&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Flyway re-applies the baseline script&lt;br&gt;
→ Use baselineOnMigrate=true and never modify a baseline&lt;/li&gt;
&lt;li&gt;Passwords in config files&lt;br&gt;
→ Use secrets and environment variables in CI/CD&lt;/li&gt;
&lt;li&gt;Manual schema drift&lt;br&gt;
→ Disable direct DB access in prod or run flyway validate periodically&lt;/li&gt;
&lt;li&gt;Need rollback support&lt;br&gt;
→ Write manual undo scripts or use point-in-time backups&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;alternatives-and-why-flyway-wins-&quot;&gt;Alternatives (and Why Flyway Wins)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt; Liquibase&lt;/strong&gt; &lt;br&gt;
✅ Pros: Advanced features, rollback support &lt;br&gt;
❌ Cons: More complex, steeper learning curve&lt;/p&gt;
&lt;p&gt;&lt;strong&gt; Django ORM / Alembic&lt;/strong&gt; &lt;br&gt;
✅ Pros: Built-in for app devs &lt;br&gt;
❌ Cons: Not cross-language or DB-agnostic&lt;/p&gt;
&lt;p&gt;&lt;strong&gt; Manual SQL or DB dumps&lt;/strong&gt; &lt;br&gt;
✅ Pros: Simple &lt;br&gt;
❌ Cons: No versioning, no tracking, no automation&lt;/p&gt;
&lt;p&gt;Flyway is a great middle-ground: powerful enough for teams, simple enough for solo devs, and perfect for CI/CD.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Flyway isn’t just for teams writing migrations every day. It’s also ideal for bringing an existing database under version control, giving your project a foundation for safe schema evolution.&lt;/p&gt;
&lt;p&gt;We started with a baseline, added our first real migration, and then built it into our CI/CD pipeline — all in a day’s work.&lt;/p&gt;
&lt;p&gt;If you’re managing an existing DB without schema tracking, now’s the time to fix that. Start with a baseline. Your future self will thank you.&lt;/p&gt;
&lt;h3 id=&quot;let-s-connect-&quot;&gt;Let’s Connect!&lt;/h3&gt;
&lt;p&gt;Have you tried Flyway for baseline-only use cases or integrated it into your CI/CD pipeline? I’d love to hear your experience in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;If you found this helpful, follow me here on DEV for more DevOps and database engineering content.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32961/flyway-migration-from-legacy-database-to-ci-cd</guid>
<pubDate>Thu, 29 Oct 2026 12:05:30 +0000</pubDate>
</item>
<item>
<title>Database Transactions - ACID Properties</title>
<link>https://coderlegion.com/32911/database-transactions-acid-properties</link>
<description>&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Yesterday, I went to a restaurant with my friends. We ordered some food, finished eating, and asked the waiter for the bill.&lt;/p&gt;
&lt;p&gt;While the waiter was preparing the bill, I started thinking about how the billing system works behind the scenes.&lt;/p&gt;
&lt;p&gt;When we place an order, the system has to do multiple things, like saving the order details, calculating the bill, and updating the payment status.&lt;/p&gt;
&lt;p&gt;But what if something goes wrong in the middle? What if the system crashes before completing all these operations?&lt;/p&gt;
&lt;p&gt;This is where database transactions come into the picture.&lt;/p&gt;
&lt;p&gt;Recently, I was learning about database transactions, ACID properties, and isolation levels. In this article, I'll share what I understood, along with some simple examples.&lt;/p&gt;
&lt;h3 id=&quot;what-is-a-database-transaction-&quot;&gt;What is a Database Transaction?&lt;/h3&gt;
&lt;p&gt;A database transaction is a group of one or more database operations that are treated as a single unit of work.&lt;/p&gt;
&lt;p&gt;Let's take our restaurant example.&lt;/p&gt;
&lt;p&gt;When a customer places an order, the system might need to perform these operations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create an order.&lt;/li&gt;
&lt;li&gt;Save the ordered items.&lt;/li&gt;
&lt;li&gt;Update the available stock.&lt;/li&gt;
&lt;li&gt;Calculate and save the total bill.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All these operations are related. If one of them fails, we don't want the database to be left with incomplete information.&lt;/p&gt;
&lt;p&gt;So, instead of executing them as separate independent operations, we can group them into a single transaction.&lt;/p&gt;
&lt;p&gt;A transaction generally involves three important commands:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;BEGIN&lt;/strong&gt; — Starts a transaction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;COMMIT&lt;/strong&gt; — Saves the changes made by the transaction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ROLLBACK&lt;/strong&gt; — Undoes the uncommitted changes if something goes wrong.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

INSERT INTO orders (order_id, table_id, total_amount)
VALUES (101, 5, 500);

INSERT INTO order_items (order_id, item_id, quantity)
VALUES (101, 10, 2);

UPDATE inventory
SET quantity = quantity - 2
WHERE item_id = 10;

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, all three database operations are part of one transaction.&lt;/p&gt;
&lt;p&gt;If everything succeeds, we commit the transaction.&lt;/p&gt;
&lt;p&gt;If an operation fails, we can roll back the transaction so that the database doesn't keep only part of the changes.&lt;/p&gt;
&lt;p&gt;But what happens if the database itself crashes while executing a transaction?&lt;/p&gt;
&lt;p&gt;A database can crash for several reasons, such as power failures, hardware problems, operating system failures, or software bugs.&lt;/p&gt;
&lt;p&gt;Even in these situations, the database needs a way to recover without leaving incomplete transactions behind.&lt;/p&gt;
&lt;p&gt;To handle these kinds of problems and maintain reliable data, database transactions follow four important properties, commonly called &lt;strong&gt;ACID properties&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;acid-properties&quot;&gt;ACID Properties&lt;/h3&gt;
&lt;p&gt;ACID stands for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A — Atomicity&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C — Consistency&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;I — Isolation&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;D — Durability&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each property solves a different problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Atomicity&lt;/strong&gt; ensures that either all operations in a transaction happen or none of them happen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consistency&lt;/strong&gt; ensures that a transaction preserves the rules and constraints defined in the database.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Isolation&lt;/strong&gt; controls how multiple transactions running at the same time interact with each other.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Durability&lt;/strong&gt; ensures that once a transaction is committed, its changes survive a crash, under the database's durability guarantees.&lt;/p&gt;
&lt;p&gt;Let's understand each property with examples.&lt;/p&gt;
&lt;h3 id=&quot;atomicity&quot;&gt;Atomicity&lt;/h3&gt;
&lt;p&gt;Atomicity means that either all the operations in a transaction should succeed, or none of them should happen.&lt;/p&gt;
&lt;p&gt;Let's take our restaurant example.&lt;/p&gt;
&lt;p&gt;Suppose a customer orders 2 pizzas, and each pizza costs ₹200.&lt;/p&gt;
&lt;p&gt;When the waiter confirms the order, the system needs to perform two operations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create an order with a total amount of ₹400.&lt;/li&gt;
&lt;li&gt;Reduce the available pizza stock by 2.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Now, let's say the order is created successfully, but the system crashes before updating the stock.&lt;/p&gt;
&lt;p&gt;In this case, the order exists in the database, but the stock has not been updated. This leaves the data in an incorrect state.&lt;/p&gt;
&lt;p&gt;To avoid this, we can execute both operations inside a transaction.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

INSERT INTO orders (order_id, amount)
VALUES (101, 400);

UPDATE inventory
SET quantity = quantity - 2
WHERE item_id = 1;

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If both operations succeed, the transaction is committed.&lt;/p&gt;
&lt;p&gt;But if something goes wrong before the transaction is committed, the database rolls back the changes.&lt;/p&gt;
&lt;p&gt;So, even if the order was inserted successfully, it won't remain in the database if the transaction fails.&lt;/p&gt;
&lt;p&gt;This is what atomicity guarantees: &lt;strong&gt;All or nothing.&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;how-does-the-database-maintain-atomicity-&quot;&gt;How does the database maintain atomicity?&lt;/h4&gt;
&lt;p&gt;Databases use transaction logs and recovery mechanisms to handle failures.&lt;/p&gt;
&lt;p&gt;For example, PostgreSQL uses &lt;strong&gt;Write-Ahead Logging (WAL)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Before modified data pages are written to their final location on disk, PostgreSQL records the changes in WAL.&lt;/p&gt;
&lt;p&gt;If the database crashes, it uses WAL and transaction status information during recovery to ensure that committed transactions are recovered and incomplete transactions do not become visible as committed changes.&lt;/p&gt;
&lt;p&gt;We'll understand WAL in more detail when we discuss durability.&lt;/p&gt;
&lt;h3 id=&quot;consistency&quot;&gt;Consistency&lt;/h3&gt;
&lt;p&gt;Consistency means that a transaction should maintain the rules and constraints defined in the database.&lt;/p&gt;
&lt;p&gt;While learning about consistency, I came across two different concepts: &lt;strong&gt;Data Consistency&lt;/strong&gt; and &lt;strong&gt;Read Consistency&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;1-data-consistency-correctness-&quot;&gt;1. Data Consistency (Correctness)&lt;/h4&gt;
&lt;p&gt;Data consistency is about keeping the data correct and making sure it follows the rules defined in the database.&lt;/p&gt;
&lt;p&gt;Let's say we have three tables.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Blogs Table&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;blog_id &lt;/th&gt;&lt;th&gt;likes&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Users Table&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;user_id &lt;/th&gt;&lt;th&gt;name&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;John&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;Edmon&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Pictures Table&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;user_id &lt;/th&gt;&lt;th&gt;picture_id&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Here, &lt;code&gt;user_id&lt;/code&gt; in the Pictures table is a foreign key referencing the Users table.&lt;/p&gt;
&lt;p&gt;Now suppose we try to insert a picture with &lt;code&gt;user_id = 5&lt;/code&gt;, but there is no user with that ID in the Users table.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO pictures (user_id, picture_id)
VALUES (5, 5);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The database rejects this operation because it violates the foreign key constraint.&lt;/p&gt;
&lt;p&gt;This is called &lt;strong&gt;referential integrity&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Similarly, databases maintain data consistency using constraints such as primary keys, foreign keys, UNIQUE, NOT NULL, and CHECK.&lt;/p&gt;
&lt;p&gt;Apart from these constraints, we may also have business rules that need to be maintained by our application or database.&lt;/p&gt;
&lt;p&gt;So, the main idea of ACID Consistency is that &lt;strong&gt;a transaction should move the database from one valid state to another valid state without breaking the defined rules.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In simple terms, it focuses on &lt;strong&gt;correctness of data&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;2-read-consistency-availability-and-freshness-&quot;&gt;2. Read Consistency (Availability and Freshness)&lt;/h4&gt;
&lt;p&gt;Now let's look at consistency from another perspective.&lt;/p&gt;
&lt;p&gt;In real applications, we may have multiple copies of the same database, called replicas.&lt;/p&gt;
&lt;p&gt;Why do we need replicas?&lt;/p&gt;
&lt;p&gt;One reason is availability. If one database server goes down, another server may still be able to serve requests. Replicas can also help distribute read traffic.&lt;/p&gt;
&lt;p&gt;Let's say we have one primary database and two replicas.&lt;/p&gt;
&lt;p&gt;Initially, all three databases contain the same value.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Primary Database → 90&lt;/li&gt;
&lt;li&gt;Replica 1 → 90&lt;/li&gt;
&lt;li&gt;Replica 2 → 90&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now suppose a transaction updates the value from &lt;code&gt;90&lt;/code&gt; to &lt;code&gt;100&lt;/code&gt; in the primary database and commits.&lt;/p&gt;
&lt;p&gt;But replication may take some time.&lt;/p&gt;
&lt;p&gt;At that moment, the data might look like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Primary Database → 100&lt;/li&gt;
&lt;li&gt;Replica 1 → 90&lt;/li&gt;
&lt;li&gt;Replica 2 → 90&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now imagine two users trying to read that value.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;T1:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;User 1 reads from the primary database and gets &lt;code&gt;100&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;T2:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;User 2 reads from Replica 1 and gets &lt;code&gt;90&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Both users are reading the same record, but they are getting different values.&lt;/p&gt;
&lt;p&gt;This happens because the replicas haven't received the latest update yet.&lt;/p&gt;
&lt;p&gt;After some time, replication catches up.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Primary Database → 100&lt;/li&gt;
&lt;li&gt;Replica 1 → 100&lt;/li&gt;
&lt;li&gt;Replica 2 → 100&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now all three databases have the same value.&lt;/p&gt;
&lt;p&gt;This is an example of &lt;strong&gt;Eventual Consistency&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It means different replicas may temporarily have different values, but eventually they become consistent if updates stop and replication completes successfully.&lt;/p&gt;
&lt;h4 id=&quot;availability-vs-consistency&quot;&gt;Availability vs. Consistency&lt;/h4&gt;
&lt;p&gt;Here, we have an interesting trade-off.&lt;/p&gt;
&lt;p&gt;Suppose a replica hasn't received the latest update yet.&lt;/p&gt;
&lt;p&gt;We have two possible approaches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option 1: Allow the read&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We can allow the user to read from that replica, even though it has older data.&lt;/p&gt;
&lt;p&gt;The user gets a response immediately, but the value might be stale.&lt;/p&gt;
&lt;p&gt;This favors availability, although it doesn't guarantee that the user gets the latest data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option 2: Wait for the latest data&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We can wait until the replica catches up, or route the request to a database that has the required version.&lt;/p&gt;
&lt;p&gt;The user gets fresher data, but the request may take longer or become unavailable if the required data cannot be reached.&lt;/p&gt;
&lt;p&gt;So, depending on the system's requirements, we may need to make trade-offs between availability and the consistency of reads, especially during network failures.&lt;/p&gt;
&lt;h4 id=&quot;what-s-the-difference-&quot;&gt;What's the Difference?&lt;/h4&gt;
&lt;p&gt;The main thing I understood is that these two concepts focus on different problems.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ACID Consistency → Correctness&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It makes sure the database follows its defined rules and constraints. A transaction should not leave the data in an invalid state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read Consistency → Freshness of Data&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It defines what values a reader is allowed to see, especially when data is replicated across multiple servers.&lt;/p&gt;
&lt;p&gt;Availability is related to read consistency because allowing reads from replicas with slightly older data can help a system continue serving requests.&lt;/p&gt;
&lt;p&gt;So, &lt;strong&gt;ACID consistency is mainly about keeping data correct, while consistency in distributed reads is about how up-to-date and consistent the data needs to be across different replicas.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;isolation&quot;&gt;Isolation&lt;/h3&gt;
&lt;p&gt;Until now, we have seen how transactions maintain atomicity and consistency.&lt;/p&gt;
&lt;p&gt;But what happens when multiple transactions run at the same time?&lt;/p&gt;
&lt;p&gt;Let's take our restaurant example.&lt;/p&gt;
&lt;p&gt;Suppose two waiters are taking orders from different tables. Both are using the same billing system.&lt;/p&gt;
&lt;p&gt;Now imagine only one pizza is left in stock, and both waiters are trying to place an order for it.&lt;/p&gt;
&lt;p&gt;If both transactions read the available quantity at the same time, they might both think the pizza is available.&lt;/p&gt;
&lt;p&gt;This is where &lt;strong&gt;Isolation&lt;/strong&gt; comes in.&lt;/p&gt;
&lt;p&gt;Isolation controls how multiple transactions running at the same time interact with each other.&lt;/p&gt;
&lt;p&gt;Ideally, we want the result of concurrent transactions to be the same as some valid order of executing them one after another. But running every transaction one after another can affect performance.&lt;/p&gt;
&lt;p&gt;So databases provide different isolation levels.&lt;/p&gt;
&lt;p&gt;Before understanding them, let's look at four common problems that happen when transactions execute concurrently.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dirty Read&lt;/li&gt;
&lt;li&gt;Non-Repeatable Read&lt;/li&gt;
&lt;li&gt;Phantom Read&lt;/li&gt;
&lt;li&gt;Lost Update&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;1-dirty-read&quot;&gt;1. Dirty Read&lt;/h4&gt;
&lt;p&gt;A dirty read happens when a transaction reads a change made by another transaction that hasn't committed yet.&lt;/p&gt;
&lt;p&gt;Let's take a sales table.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;pid &lt;/th&gt;&lt;th&gt;qnt &lt;/th&gt;&lt;th&gt;price&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;P1 &lt;/td&gt;&lt;td&gt;10 &lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;P2 &lt;/td&gt;&lt;td&gt;20 &lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Suppose two transactions are running.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;TX1 &lt;/th&gt;&lt;th&gt;TX2&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;T1 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T2 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT pid, qnt * price FROM sales;&lt;/code&gt; → P1: 50, P2: 80 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T3 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T4 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE sales SET qnt = qnt + 5 WHERE pid = 'P1';&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T5 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT SUM(qnt * price) FROM sales;&lt;/code&gt; → &lt;strong&gt;155&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T6 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ROLLBACK;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T7 &lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;At T5, TX1 reads the updated value from TX2 even though TX2 hasn't committed yet.&lt;/p&gt;
&lt;p&gt;Later, TX2 rolls back its changes, but TX1 has already used that uncommitted data.&lt;/p&gt;
&lt;p&gt;This is called a &lt;strong&gt;Dirty Read&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL note:&lt;/strong&gt; This example represents a database that allows dirty reads. PostgreSQL doesn't allow them, even with Read Uncommitted.&lt;/p&gt;
&lt;h4 id=&quot;2-non-repeatable-read&quot;&gt;2. Non-Repeatable Read&lt;/h4&gt;
&lt;p&gt;A non-repeatable read happens when a transaction reads the same record twice and gets different values because another transaction committed an update in between.&lt;/p&gt;
&lt;p&gt;Let's use the same Sales table.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;TX1 &lt;/th&gt;&lt;th&gt;TX2&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;T1 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T2 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT pid, qnt * price FROM sales;&lt;/code&gt; → P1: 50, P2: 80 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T3 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T4 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE sales SET qnt = qnt + 5 WHERE pid = 'P1';&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T5 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T6 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT SUM(qnt * price) FROM sales;&lt;/code&gt; → &lt;strong&gt;155&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T7 &lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Here, TX1 initially reads the sales values.&lt;/p&gt;
&lt;p&gt;Meanwhile, TX2 updates the quantity and commits.&lt;/p&gt;
&lt;p&gt;When TX1 later calculates the total, it sees the updated data.&lt;/p&gt;
&lt;p&gt;So TX1 gets different results within the same transaction.&lt;/p&gt;
&lt;p&gt;This demonstrates the effect of a non-repeatable read. Strictly speaking, the standard definition involves reading the same row twice; here, the second statement is an aggregation over the changed rows.&lt;/p&gt;
&lt;p&gt;This can happen under PostgreSQL's default Read Committed isolation level.&lt;/p&gt;
&lt;h4 id=&quot;3-phantom-read&quot;&gt;3. Phantom Read&lt;/h4&gt;
&lt;p&gt;A phantom read happens when a transaction executes a query and later gets a different set of matching rows because another transaction inserts, deletes, or changes matching records.&lt;/p&gt;
&lt;p&gt;Suppose we have this table:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;id &lt;/th&gt;&lt;th&gt;ch&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;a&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;a&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;b&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4 &lt;/td&gt;&lt;td&gt;b&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;TX1 &lt;/th&gt;&lt;th&gt;TX2&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;T1 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T2 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT * FROM test WHERE ch = 'b';&lt;/code&gt; → &lt;strong&gt;2 rows&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T3 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T4 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;INSERT INTO test (id, ch) VALUES (5, 'b');&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T5 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T6 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT * FROM test WHERE ch = 'b';&lt;/code&gt; → &lt;strong&gt;3 rows&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T7 &lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Initially, TX1 gets two records.&lt;/p&gt;
&lt;p&gt;Meanwhile, TX2 inserts another record with &lt;code&gt;ch = 'b'&lt;/code&gt; and commits.&lt;/p&gt;
&lt;p&gt;When TX1 executes the same query again, it gets three records.&lt;/p&gt;
&lt;p&gt;The newly appearing record is called a &lt;strong&gt;phantom row&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;PostgreSQL's Repeatable Read prevents phantom reads because the transaction continues using the same snapshot.&lt;/p&gt;
&lt;h4 id=&quot;4-lost-update&quot;&gt;4. Lost Update&lt;/h4&gt;
&lt;p&gt;A lost update happens when two transactions read the same value and update it independently, causing one transaction's change to be overwritten.&lt;/p&gt;
&lt;p&gt;Suppose P1's current quantity is &lt;code&gt;10&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;TX1 wants to increase it by 10, and TX2 wants to increase it by 5.&lt;/p&gt;
&lt;p&gt;The expected final quantity is &lt;code&gt;25&lt;/code&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;TX1 &lt;/th&gt;&lt;th&gt;TX2&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;T1 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T2 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT qnt FROM sales WHERE pid = 'P1';&lt;/code&gt; → &lt;strong&gt;10&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T3 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T4 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT qnt FROM sales WHERE pid = 'P1';&lt;/code&gt; → &lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T5 &lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE sales SET qnt = 20 WHERE pid = 'P1';&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T6 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE sales SET qnt = 15 WHERE pid = 'P1';&lt;/code&gt; → Waits for TX1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T7 &lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T8 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;TX2's UPDATE completes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T9 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The final quantity becomes &lt;code&gt;15&lt;/code&gt; instead of &lt;code&gt;25&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;TX2 overwrites the value written by TX1, so TX1's update is lost.&lt;/p&gt;
&lt;p&gt;This is called a &lt;strong&gt;Lost Update&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This can happen when an application calculates a new value using an earlier read and then writes that fixed value back.&lt;/p&gt;
&lt;p&gt;One way to prevent it is by using &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

SELECT qnt
FROM sales
WHERE pid = 'P1'
FOR UPDATE;

UPDATE sales
SET qnt = qnt + 10
WHERE pid = 'P1';

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, PostgreSQL locks the selected row. Another transaction trying to update the same row must wait.&lt;/p&gt;
&lt;p&gt;If we simply want to increment a value, we can also do it directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE sales
SET qnt = qnt + 5
WHERE pid = 'P1';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This avoids calculating the new value using an outdated application-side read.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;database-isolation-levels&quot;&gt;Database Isolation Levels&lt;/h3&gt;
&lt;p&gt;Now that we have seen these problems, let's understand how databases prevent them.&lt;/p&gt;
&lt;p&gt;SQL defines four common isolation levels:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read Uncommitted&lt;/li&gt;
&lt;li&gt;Read Committed&lt;/li&gt;
&lt;li&gt;Repeatable Read&lt;/li&gt;
&lt;li&gt;Serializable&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;1-read-uncommitted&quot;&gt;1. Read Uncommitted&lt;/h4&gt;
&lt;p&gt;This is the weakest standard isolation level.&lt;/p&gt;
&lt;p&gt;A transaction may read changes made by another transaction before they are committed.&lt;/p&gt;
&lt;p&gt;So dirty reads, non-repeatable reads, and phantom reads are possible.&lt;/p&gt;
&lt;p&gt;For example, in our Dirty Read scenario, TX1 could read the updated sales total even though TX2 later rolls back.&lt;/p&gt;
&lt;p&gt;We can request this isolation level using:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN TRANSACTION
ISOLATION LEVEL READ UNCOMMITTED;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, PostgreSQL treats Read Uncommitted as Read Committed.&lt;/p&gt;
&lt;p&gt;So we cannot reproduce dirty reads in PostgreSQL.&lt;/p&gt;
&lt;h4 id=&quot;2-read-committed&quot;&gt;2. Read Committed&lt;/h4&gt;
&lt;p&gt;Read Committed prevents dirty reads.&lt;/p&gt;
&lt;p&gt;A transaction only reads committed data.&lt;/p&gt;
&lt;p&gt;However, each statement gets a new snapshot, so the same query may return different results when executed twice.&lt;/p&gt;
&lt;p&gt;Let's take our sales example.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;TX1 &lt;/th&gt;&lt;th&gt;TX2&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;T1 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN ISOLATION LEVEL READ COMMITTED;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T2 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT pid, qnt * price FROM sales;&lt;/code&gt; → P1: 50, P2: 80 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T3 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T4 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE sales SET qnt = qnt + 5 WHERE pid = 'P1';&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T5 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T6 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT SUM(qnt * price) FROM sales;&lt;/code&gt; → &lt;strong&gt;155&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T7 &lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Even though TX1 hasn't finished, its next query sees the changes committed by TX2.&lt;/p&gt;
&lt;p&gt;This is allowed because Read Committed creates a new snapshot for each statement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read Committed is PostgreSQL's default isolation level.&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;3-repeatable-read&quot;&gt;3. Repeatable Read&lt;/h4&gt;
&lt;p&gt;In Repeatable Read, a transaction continues reading from the same snapshot.&lt;/p&gt;
&lt;p&gt;Let's use the same example.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;TX1 &lt;/th&gt;&lt;th&gt;TX2&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;T1 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN ISOLATION LEVEL REPEATABLE READ;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T2 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT pid, qnt * price FROM sales;&lt;/code&gt; → P1: 50, P2: 80 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T3 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T4 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE sales SET qnt = qnt + 5 WHERE pid = 'P1';&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T5 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T6 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT SUM(qnt * price) FROM sales;&lt;/code&gt; → &lt;strong&gt;130&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T7 &lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Notice the difference.&lt;/p&gt;
&lt;p&gt;Even though TX2 committed its update, TX1 still calculates the total using its original snapshot.&lt;/p&gt;
&lt;p&gt;This happens because PostgreSQL uses a consistent snapshot established when the transaction first executes a statement that needs one.&lt;/p&gt;
&lt;p&gt;So changes committed by other transactions after that snapshot aren't visible to TX1.&lt;/p&gt;
&lt;p&gt;One interesting thing about PostgreSQL is that &lt;strong&gt;Repeatable Read also prevents phantom reads&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This is stronger than the minimum Repeatable Read guarantees described by the SQL standard.&lt;/p&gt;
&lt;p&gt;However, Repeatable Read doesn't prevent every possible concurrency anomaly.&lt;/p&gt;
&lt;p&gt;For example, write skew can still happen.&lt;/p&gt;
&lt;h4 id=&quot;4-serializable&quot;&gt;4. Serializable&lt;/h4&gt;
&lt;p&gt;Serializable is the strongest standard isolation level.&lt;/p&gt;
&lt;p&gt;It ensures that concurrent transactions produce a result equivalent to executing those transactions one after another in some order.&lt;/p&gt;
&lt;p&gt;This doesn't mean PostgreSQL actually runs every transaction sequentially.&lt;/p&gt;
&lt;p&gt;Transactions can still run concurrently.&lt;/p&gt;
&lt;p&gt;But PostgreSQL checks for dangerous dependencies that could produce a result that isn't equivalent to any serial execution.&lt;/p&gt;
&lt;p&gt;PostgreSQL uses &lt;strong&gt;Serializable Snapshot Isolation (SSI)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Let's take an example.&lt;/p&gt;
&lt;p&gt;Suppose we have two records.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Record &lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;A &lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;B &lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Our business rule is that at least one record must always have a value of 100.&lt;/p&gt;
&lt;p&gt;Two transactions are running at Serializable isolation.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;TX1 &lt;/th&gt;&lt;th&gt;TX2&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;T1 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN ISOLATION LEVEL SERIALIZABLE;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T2 &lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT value FROM test WHERE id IN ('A', 'B');&lt;/code&gt; → &lt;strong&gt;100, 100&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T3 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BEGIN ISOLATION LEVEL SERIALIZABLE;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T4 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;SELECT value FROM test WHERE id IN ('A', 'B');&lt;/code&gt; → &lt;strong&gt;100, 100&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T5 &lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE test SET value = 0 WHERE id = 'A';&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T6 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE test SET value = 0 WHERE id = 'B';&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T7 &lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T8 &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT;&lt;/code&gt; → &lt;strong&gt;Serialization failure&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Both transactions initially read A = 100 and B = 100.&lt;/p&gt;
&lt;p&gt;TX1 changes A to 0.&lt;/p&gt;
&lt;p&gt;TX2 changes B to 0.&lt;/p&gt;
&lt;p&gt;If both transactions committed successfully, we would have:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;A = 0
B = 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This would violate our business rule.&lt;/p&gt;
&lt;p&gt;This kind of anomaly is called &lt;strong&gt;Write Skew&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Under PostgreSQL Repeatable Read, both transactions may successfully commit because they update different rows.&lt;/p&gt;
&lt;p&gt;But PostgreSQL Serializable can detect the dangerous dependency and abort one transaction.&lt;/p&gt;
&lt;p&gt;We may see an error like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ERROR: could not serialize access due to
read/write dependencies among transactions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When this happens, the application needs to retry the entire transaction.&lt;/p&gt;
&lt;p&gt;Serializable is useful when we need stronger correctness guarantees, but we should also be prepared to handle transaction retries.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pessimistic-and-optimistic-concurrency-control&quot;&gt;Pessimistic and Optimistic Concurrency Control&lt;/h3&gt;
&lt;p&gt;While learning about isolation, I also came across two ways of handling concurrent updates.&lt;/p&gt;
&lt;h4 id=&quot;pessimistic-concurrency-control&quot;&gt;Pessimistic Concurrency Control&lt;/h4&gt;
&lt;p&gt;Here, we lock the data before changing it.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

SELECT qnt
FROM sales
WHERE pid = 'P1'
FOR UPDATE;

UPDATE sales
SET qnt = qnt + 10
WHERE pid = 'P1';

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;FOR UPDATE&lt;/code&gt; statement locks the selected row.&lt;/p&gt;
&lt;p&gt;Another transaction trying to acquire a conflicting lock must wait until the first transaction releases it.&lt;/p&gt;
&lt;p&gt;This is useful when conflicts are likely.&lt;/p&gt;
&lt;h4 id=&quot;optimistic-concurrency-control&quot;&gt;Optimistic Concurrency Control&lt;/h4&gt;
&lt;p&gt;In optimistic concurrency control, we allow transactions to proceed without locking the record exclusively in advance.&lt;/p&gt;
&lt;p&gt;Before applying the update, we check whether another transaction has changed the data.&lt;/p&gt;
&lt;p&gt;If a conflicting change is detected, the operation may fail and need to be retried.&lt;/p&gt;
&lt;p&gt;This approach can be useful when conflicts are less frequent.&lt;/p&gt;
&lt;p&gt;PostgreSQL's Serializable Snapshot Isolation also uses conflict detection, although its implementation is different from application-level version checking.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;comparison-of-isolation-levels&quot;&gt;Comparison of Isolation Levels&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Isolation Level &lt;/th&gt;&lt;th&gt;Dirty Read &lt;/th&gt;&lt;th&gt;Non-Repeatable Read &lt;/th&gt;&lt;th&gt;Phantom Read&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Read Uncommitted &lt;/td&gt;&lt;td&gt;Possible &lt;/td&gt;&lt;td&gt;Possible &lt;/td&gt;&lt;td&gt;Possible&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Read Committed &lt;/td&gt;&lt;td&gt;Prevented &lt;/td&gt;&lt;td&gt;Possible &lt;/td&gt;&lt;td&gt;Possible&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Repeatable Read &lt;/td&gt;&lt;td&gt;Prevented &lt;/td&gt;&lt;td&gt;Prevented &lt;/td&gt;&lt;td&gt;Possible under SQL standard&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Serializable &lt;/td&gt;&lt;td&gt;Prevented &lt;/td&gt;&lt;td&gt;Prevented &lt;/td&gt;&lt;td&gt;Prevented&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This table describes the general SQL-standard guarantees.&lt;/p&gt;
&lt;p&gt;PostgreSQL has two important differences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read Uncommitted&lt;/strong&gt; behaves like Read Committed, so dirty reads are prevented.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repeatable Read&lt;/strong&gt; also prevents phantom reads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Lost updates need separate consideration because their behavior depends on the type of update and the database's concurrency-control mechanisms.&lt;/p&gt;
&lt;h4 id=&quot;what-i-understood-about-isolation&quot;&gt;What I Understood About Isolation&lt;/h4&gt;
&lt;p&gt;Isolation is not simply about making transactions run one after another.&lt;/p&gt;
&lt;p&gt;It is about controlling how concurrent transactions interact.&lt;/p&gt;
&lt;p&gt;Different isolation levels provide different guarantees, and the right choice depends on what our application needs.&lt;/p&gt;
&lt;p&gt;For many applications, Read Committed is sufficient.&lt;/p&gt;
&lt;p&gt;But when multiple queries need to see a consistent snapshot, Repeatable Read may be useful.&lt;/p&gt;
&lt;p&gt;And when concurrent transactions must behave like some serial execution, Serializable provides the strongest guarantee.&lt;/p&gt;
&lt;p&gt;The important thing is to understand what problems each isolation level prevents and choose accordingly.&lt;/p&gt;
&lt;h3 id=&quot;durability&quot;&gt;Durability&lt;/h3&gt;
&lt;p&gt;Durability means that once a transaction is committed, its changes should not be lost, even if the database crashes or restarts.&lt;/p&gt;
&lt;p&gt;Let's take our restaurant example.&lt;/p&gt;
&lt;p&gt;Suppose a customer finishes eating, pays the bill, and the waiter confirms the payment in the billing system.&lt;/p&gt;
&lt;p&gt;The database successfully commits the transaction.&lt;/p&gt;
&lt;p&gt;But immediately after that, the database server crashes.&lt;/p&gt;
&lt;p&gt;When the server restarts, the payment information should still be there.&lt;/p&gt;
&lt;p&gt;This is what &lt;strong&gt;Durability&lt;/strong&gt; guarantees.&lt;/p&gt;
&lt;h4 id=&quot;how-do-databases-maintain-durability-&quot;&gt;How do databases maintain durability?&lt;/h4&gt;
&lt;p&gt;Databases use different techniques to make sure committed changes survive failures.&lt;/p&gt;
&lt;p&gt;Some common techniques are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Write-Ahead Logging (WAL)&lt;/li&gt;
&lt;li&gt;OS Page Cache and fsync&lt;/li&gt;
&lt;li&gt;Checkpoints&lt;/li&gt;
&lt;li&gt;Asynchronous Snapshots&lt;/li&gt;
&lt;li&gt;Append-Only Files (AOF)&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;1-write-ahead-logging-wal-&quot;&gt;1. Write-Ahead Logging (WAL)&lt;/h4&gt;
&lt;p&gt;Writing every change directly to the database files can be expensive.&lt;/p&gt;
&lt;p&gt;For example, when we update a record, the database may need to modify multiple data pages and indexes.&lt;/p&gt;
&lt;p&gt;Instead of writing all these changes to their final locations immediately, databases like PostgreSQL use &lt;strong&gt;Write-Ahead Logging (WAL)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The idea is simple.&lt;/p&gt;
&lt;p&gt;Before modified data pages are written to disk, the database first records the changes in a log.&lt;/p&gt;
&lt;p&gt;Let's see how PostgreSQL handles an UPDATE query.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

UPDATE orders
SET payment_status = 'Paid'
WHERE order_id = 101;

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When PostgreSQL executes this query, it modifies the data page in shared buffers and creates the required WAL records in WAL buffers.&lt;/p&gt;
&lt;p&gt;Both are initially in RAM.&lt;/p&gt;
&lt;p&gt;Under normal synchronous commit settings, PostgreSQL persists the required WAL before confirming the transaction.&lt;/p&gt;
&lt;p&gt;The modified data pages can be written to disk later.&lt;/p&gt;
&lt;p&gt;Here is how the process works.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/nnf3t3ex02k7k9tmdhrv.jpg&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Now suppose the database crashes after COMMIT, before the modified data pages are written to disk.&lt;/p&gt;
&lt;p&gt;When PostgreSQL restarts, it can use the persisted WAL records to recover the committed changes.&lt;/p&gt;
&lt;p&gt;This is why WAL plays an important role in durability.&lt;/p&gt;
&lt;h4 id=&quot;2-os-page-cache-and-fsync&quot;&gt;2. OS Page Cache and fsync&lt;/h4&gt;
&lt;p&gt;One interesting thing I learned is that &lt;strong&gt;writing data to a file doesn't always mean it has reached the physical disk&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Usually, when a program writes to a file, the operating system may first keep that data in its page cache, which is part of RAM.&lt;/p&gt;
&lt;p&gt;Why?&lt;/p&gt;
&lt;p&gt;Because writing to RAM is much faster than writing directly to disk. The operating system can write the cached data to disk later.&lt;/p&gt;
&lt;p&gt;But how is this related to database durability?&lt;/p&gt;
&lt;p&gt;Let's take our PostgreSQL example.&lt;/p&gt;
&lt;p&gt;When PostgreSQL executes an UPDATE query, it modifies the data in shared buffers and generates WAL records in WAL buffers.&lt;/p&gt;
&lt;p&gt;Both are initially in RAM.&lt;/p&gt;
&lt;p&gt;When PostgreSQL writes these WAL records to its WAL file, they may first go through the operating system's page cache.&lt;/p&gt;
&lt;p&gt;So even though PostgreSQL has written the WAL to a file, the data might still be sitting in RAM.&lt;/p&gt;
&lt;p&gt;The flow looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/24mzvx9g7ybskuh6lwfv.jpg&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Now suppose the WAL has been written to the OS page cache, but it hasn't reached the disk yet.&lt;/p&gt;
&lt;p&gt;If the machine suddenly loses power, the cached WAL data may be lost.&lt;/p&gt;
&lt;p&gt;And if the modified data page was also only in RAM, PostgreSQL might lose that change.&lt;/p&gt;
&lt;p&gt;This is where &lt;strong&gt;fsync()&lt;/strong&gt; comes in.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fsync()&lt;/code&gt; is an operating system call that requests the modified data of a file to be synchronized to stable storage.&lt;/p&gt;
&lt;p&gt;In simple terms, it helps make sure the data isn't just sitting in the OS page cache.&lt;/p&gt;
&lt;p&gt;Under normal durability settings, PostgreSQL uses &lt;code&gt;fsync()&lt;/code&gt; or other appropriate synchronization methods to persist the required WAL before confirming a synchronous commit.&lt;/p&gt;
&lt;p&gt;So even if the database crashes immediately after COMMIT, PostgreSQL can use the persisted WAL to recover the changes.&lt;/p&gt;
&lt;p&gt;The important difference is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;WAL records the changes needed for recovery, while fsync or a similar synchronization mechanism helps make those WAL records durable.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There is also a performance cost. Synchronizing data to storage takes time, so frequent synchronization can increase write latency.&lt;/p&gt;
&lt;p&gt;PostgreSQL provides settings such as &lt;code&gt;fsync&lt;/code&gt; and &lt;code&gt;synchronous_commit&lt;/code&gt; to control this behavior.&lt;/p&gt;
&lt;h4 id=&quot;3-checkpoints&quot;&gt;3. Checkpoints&lt;/h4&gt;
&lt;p&gt;We have seen that PostgreSQL can commit a transaction before writing all the modified data pages to their final locations.&lt;/p&gt;
&lt;p&gt;But those modified pages cannot remain only in memory forever.&lt;/p&gt;
&lt;p&gt;They eventually need to be written to disk.&lt;/p&gt;
&lt;p&gt;This is where &lt;strong&gt;Checkpoints&lt;/strong&gt; come in.&lt;/p&gt;
&lt;p&gt;A checkpoint is a process where PostgreSQL ensures that dirty data pages (modified in memory but not yet on disk) are written to storage and records a point from which crash recovery can proceed.&lt;/p&gt;
&lt;p&gt;For example, suppose the database has been running for several hours and thousands of transactions have modified data.&lt;/p&gt;
&lt;p&gt;If the database crashes, PostgreSQL needs to replay WAL records to recover changes that weren't already reflected in the database files.&lt;/p&gt;
&lt;p&gt;Without checkpoints, the amount of WAL that needs to be processed during recovery could become very large.&lt;/p&gt;
&lt;p&gt;By performing checkpoints periodically, PostgreSQL can reduce the amount of recovery work required.&lt;/p&gt;
&lt;p&gt;So checkpoints mainly help with writing modified data pages to disk and controlling crash recovery time.&lt;/p&gt;
&lt;h4 id=&quot;4-asynchronous-snapshots&quot;&gt;4. Asynchronous Snapshots&lt;/h4&gt;
&lt;p&gt;Some databases use snapshots as a persistence mechanism.&lt;/p&gt;
&lt;p&gt;Instead of saving every change immediately, they periodically save a snapshot of their data.&lt;/p&gt;
&lt;p&gt;For example, Redis supports RDB snapshots.&lt;/p&gt;
&lt;p&gt;Suppose a database takes a snapshot at 10:00 AM.&lt;/p&gt;
&lt;p&gt;After that, some new records are created at 10:05 AM.&lt;/p&gt;
&lt;p&gt;Now imagine the server crashes at 10:06 AM, before the next snapshot is taken.&lt;/p&gt;
&lt;p&gt;If snapshots are the only persistence mechanism, those recent changes may be lost.&lt;/p&gt;
&lt;p&gt;So the durability guarantee depends on how frequently snapshots are taken and whether other persistence methods are enabled.&lt;/p&gt;
&lt;h4 id=&quot;5-append-only-files-aof-&quot;&gt;5. Append-Only Files (AOF)&lt;/h4&gt;
&lt;p&gt;Another persistence technique is an &lt;strong&gt;Append-Only File (AOF)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Redis supports this mechanism.&lt;/p&gt;
&lt;p&gt;Instead of saving the entire dataset after every update, Redis records write operations in an append-only log.&lt;/p&gt;
&lt;p&gt;When the server restarts, Redis can use the persisted AOF to rebuild its data.&lt;/p&gt;
&lt;p&gt;However, durability depends on how frequently the AOF is synchronized to disk.&lt;/p&gt;
&lt;p&gt;For example, Redis can be configured to synchronize after every write or approximately once per second.&lt;/p&gt;
&lt;p&gt;So AOF provides another way to recover data, but recent changes may still be lost depending on the persistence settings.&lt;/p&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;When I started learning about database transactions, I thought it was mostly about BEGIN, COMMIT, and ROLLBACK.&lt;/p&gt;
&lt;p&gt;But as I went deeper, I came across some interesting things, especially what happens when two transactions run at the same time and how PostgreSQL handles failures.&lt;/p&gt;
&lt;p&gt;There is still a lot more to learn about how databases work internally, but understanding these concepts helped me see what actually happens behind a simple database query.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32911/database-transactions-acid-properties</guid>
<pubDate>Thu, 29 Oct 2026 01:49:48 +0000</pubDate>
</item>
<item>
<title>Your Pagination Is Showing Users the Same Item Twice (and Skipping Others). Here's Why Offset Breaks</title>
<link>https://coderlegion.com/32501/your-pagination-showing-users-the-same-item-twice-and-skipping-others-heres-offset-breaks</link>
<description>&lt;p&gt;A user scrolls through your feed. Page 1 ends with &quot;Post 20&quot;. They tap &quot;Next&quot;, and page 2 starts with... &quot;Post 20&quot; again.&lt;/p&gt;
&lt;p&gt;Another user pages through an admin table and never sees order #4817. It exists. It is in the database. It just never showed up on any page.&lt;/p&gt;
&lt;p&gt;Nobody changed any code. The data changed underneath the pagination, and your &lt;code&gt;skip&lt;/code&gt; and &lt;code&gt;limit&lt;/code&gt; could not cope.&lt;/p&gt;
&lt;h3 id=&quot;the-code-everyone-writes&quot;&gt;The Code Everyone Writes&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.get('/api/posts', async (req, res) =&amp;gt; {
  const page = Number(req.query.page) || 1;
  const limit = 20;

  const posts = await Post.find()
    .sort({ createdAt: -1 })
    .skip((page - 1) * limit)
    .limit(limit);

  res.json(posts);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is simple, it works in development, and &lt;code&gt;?page=2&lt;/code&gt; is easy to understand. That is why it is everywhere.&lt;/p&gt;
&lt;h3 id=&quot;why-it-breaks&quot;&gt;Why It Breaks&lt;/h3&gt;
&lt;p&gt;Offset pagination says &quot;skip the first N rows, then give me the next 20&quot;. It counts positions, and positions move when the data does.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Duplicates.&lt;/strong&gt; Someone publishes a new post while a reader is on page 1. Every existing post shifts down one position. When the reader requests page 2, the last item of page 1 is now the first item of page 2.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Skips.&lt;/strong&gt; The reverse: a post is deleted while the reader is on page 1. Everything shifts up one, and the first item of what would have been page 2 slides into page 1's range. The reader never sees it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Before:  [1 2 3 4 5 | 6 7 8 9 10]    page 1 = 1-5, page 2 = 6-10
New item arrives at the top:
After:   [0 1 2 3 4 | 5 6 7 8 9]     page 2 now starts with 5, which they already saw
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-second-problem-it-gets-slower-on-deep-pages&quot;&gt;The Second Problem: It Gets Slower on Deep Pages&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;skip(100000)&lt;/code&gt; does not jump to row 100,000. The database has to walk past 100,000 documents and throw them away before returning your 20. Page 1 is fast. Page 5,000 is slow, and the cost grows with the page number.&lt;/p&gt;
&lt;p&gt;This is why a feed that feels fine with 1,000 rows slows down quietly at a million.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-cursor-pagination&quot;&gt;The Fix: Cursor Pagination&lt;/h3&gt;
&lt;p&gt;Instead of &quot;skip N&quot;, say &quot;give me the 20 items &lt;strong&gt;after this one&lt;/strong&gt;&quot;. The anchor is a &lt;strong&gt;cursor&lt;/strong&gt;: a value that identifies where the last page ended.&lt;/p&gt;
&lt;p&gt;A new item arriving at the top cannot shift your place, because you are not counting positions. You are standing on a specific item.&lt;/p&gt;
&lt;h4 id=&quot;step-1-sort-by-something-stable-with-a-tie-breaker&quot;&gt;Step 1: Sort by something stable, with a tie-breaker&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;createdAt&lt;/code&gt; alone is not enough, because two documents can share the same timestamp. Add &lt;code&gt;_id&lt;/code&gt; as a tie-breaker so the order is always exact:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;.sort({ createdAt: -1, _id: -1 })
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-support-the-query-with-an-index&quot;&gt;Step 2: Support the query with an index&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;PostSchema.index({ createdAt: -1, _id: -1 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this index, the database jumps straight to the cursor and reads 20 documents, whether you are on page 1 or page 5,000.&lt;/p&gt;
&lt;h4 id=&quot;step-3-query-after-the-cursor-&quot;&gt;Step 3: Query &quot;after the cursor&quot;&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;app.get('/api/posts', async (req, res) =&amp;gt; {
  const limit = 20;
  const cursor = req.query.cursor ? decodeCursor(req.query.cursor) : null;

  const filter = cursor
    ? {
        $or: [
          { createdAt: { $lt: cursor.createdAt } },
          { createdAt: cursor.createdAt, _id: { $lt: cursor._id } },
        ],
      }
    : {};

  const rows = await Post.find(filter)
    .sort({ createdAt: -1, _id: -1 })
    .limit(limit + 1); // fetch one extra to know if there is a next page

  const hasMore = rows.length &amp;gt; limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  const last = items[items.length - 1];

  res.json({
    items,
    nextCursor: hasMore ? encodeCursor(last) : null,
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;$or&lt;/code&gt; says: &quot;older than the cursor, or the same timestamp but a smaller &lt;code&gt;_id&lt;/code&gt;&quot;. That second branch is what stops items with identical timestamps from being skipped or repeated.&lt;/p&gt;
&lt;h4 id=&quot;step-4-encode-the-cursor-so-clients-do-not-depend-on-it&quot;&gt;Step 4: Encode the cursor so clients do not depend on it&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const encodeCursor = (doc) =&amp;gt;
  Buffer.from(JSON.stringify({ createdAt: doc.createdAt, _id: doc._id })).toString('base64url');

const decodeCursor = (str) =&amp;gt; {
  const { createdAt, _id } = JSON.parse(Buffer.from(str, 'base64url').toString());
  return { createdAt: new Date(createdAt), _id: new mongoose.Types.ObjectId(_id) };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Clients treat it as an opaque string and send it back as it is. You can change what is inside later without breaking anyone. In production, validate the decoded values and handle a malformed cursor with a &lt;code&gt;400&lt;/code&gt;, not a crash.&lt;/p&gt;
&lt;h3 id=&quot;what-you-give-up&quot;&gt;What You Give Up&lt;/h3&gt;
&lt;p&gt;Cursor pagination has a trade-off, and you should know it before switching:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No &quot;jump to page 47&quot;.&lt;/strong&gt; You can only go forward (or back, with a second cursor). For feeds and infinite scroll, that is perfect. For a numbered page bar, it is not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No cheap total count.&lt;/strong&gt; &lt;code&gt;countDocuments&lt;/code&gt; on a large collection is not free either way, so many apps drop the &quot;page 3 of 500&quot; display.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-offset-is-still-fine&quot;&gt;When Offset Is Still Fine&lt;/h3&gt;
&lt;p&gt;Do not rewrite everything. Offset pagination is a reasonable choice for small, slow-changing tables, like an admin list with a few hundred rows, where users really do want page numbers.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Feed, timeline, infinite scroll &lt;/td&gt;&lt;td&gt;Cursor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Large collection, deep pages &lt;/td&gt;&lt;td&gt;Cursor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data changes while people browse &lt;/td&gt;&lt;td&gt;Cursor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Small admin table with page numbers &lt;/td&gt;&lt;td&gt;Offset is fine&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;the-rule&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Offset pagination counts positions, and positions change. Cursor pagination anchors to a specific item, so it stays correct when data is added or removed.&lt;/strong&gt; Always sort by a unique, indexed combination, and fetch one extra row to know whether there is a next page.&lt;/p&gt;
&lt;p&gt;Quick audit: search your project for &lt;code&gt;.skip(&lt;/code&gt;. For each hit, ask whether the collection can grow past a few thousand documents, or change while someone is paging through it.&lt;/p&gt;
&lt;p&gt;If you work with MongoDB, I collect more patterns like this, around indexes and queries, in my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com/blog/mongodb-mongoose-patterns-2026&quot;&gt;MongoDB and Mongoose patterns guide&lt;/a&gt;, and you can browse my Next.js &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;templates on pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Have you ever been bitten by duplicate or missing items in a paginated list? Tell me how you noticed in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32501/your-pagination-showing-users-the-same-item-twice-and-skipping-others-heres-offset-breaks</guid>
<pubDate>Mon, 26 Oct 2026 11:11:24 +0000</pubDate>
</item>
<item>
<title>Multi-Tenant SaaS: Shared Database or One Per Tenant?</title>
<link>https://coderlegion.com/31897/multi-tenant-saas-shared-database-or-one-per-tenant</link>
<description>&lt;p&gt;If you are building a SaaS product for more than one customer, you will have to answer one early question: &lt;strong&gt;how do you separate each customer's data?&lt;/strong&gt; The choice between a shared database and a database per tenant shapes security, cost, performance and how painful your first enterprise customer will be.&lt;/p&gt;
&lt;p&gt;I make this decision with founders as part of &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/services/saas-development&quot;&gt;SaaS development&lt;/a&gt; work. This guide explains the three common models, when each fits, and the mistakes that are expensive to fix later.&lt;/p&gt;
&lt;h3 id=&quot;what-multi-tenant-means&quot;&gt;What &quot;multi-tenant&quot; means&lt;/h3&gt;
&lt;p&gt;A &lt;strong&gt;tenant&lt;/strong&gt; is one customer account, usually a company with its own users and data. A multi-tenant system serves many tenants from one codebase and one deployment. The alternative, running a separate copy of the whole app per customer, is single-tenant, and it gets hard to maintain quickly.&lt;/p&gt;
&lt;p&gt;Multi-tenant design answers two questions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Isolation:&lt;/strong&gt; how do you make sure tenant A can never see tenant B's data?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operations:&lt;/strong&gt; how do you migrate, back up, scale and support many tenants without the work growing with every new customer?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-three-data-models&quot;&gt;The three data models&lt;/h3&gt;
&lt;h4 id=&quot;1-shared-database-shared-schema&quot;&gt;1. Shared database, shared schema&lt;/h4&gt;
&lt;p&gt;All tenants live in the same tables. Each row has a &lt;code&gt;tenant_id&lt;/code&gt; column, and every query filters by it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strengths&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cheapest to run and simplest to start&lt;/li&gt;
&lt;li&gt;One migration updates everyone&lt;/li&gt;
&lt;li&gt;Easy to report across tenants&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Weaknesses&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One missed filter leaks data between customers&lt;/li&gt;
&lt;li&gt;A very large tenant can slow everyone down (&quot;noisy neighbour&quot;)&lt;/li&gt;
&lt;li&gt;Restoring a single tenant from a backup is awkward&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2-shared-database-separate-schema-per-tenant&quot;&gt;2. Shared database, separate schema per tenant&lt;/h4&gt;
&lt;p&gt;Tenants share a database server, but each gets its own schema, such as separate sets of tables.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strengths&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Stronger separation than a tenant column&lt;/li&gt;
&lt;li&gt;Per-tenant backup and restore is easier&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Weaknesses&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Migrations must run once per tenant, which gets slow with many tenants&lt;/li&gt;
&lt;li&gt;Tooling and connection handling are more complex&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-separate-database-per-tenant&quot;&gt;3. Separate database per tenant&lt;/h4&gt;
&lt;p&gt;Each tenant has its own database.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strengths&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Strongest isolation, which helps with security reviews and regulated customers&lt;/li&gt;
&lt;li&gt;A tenant's data can be moved, backed up or deleted on its own&lt;/li&gt;
&lt;li&gt;Heavy tenants don't affect the others' queries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Weaknesses&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Highest operational cost and complexity&lt;/li&gt;
&lt;li&gt;Migrations and monitoring multiply with each tenant&lt;/li&gt;
&lt;li&gt;Cross-tenant reporting needs extra work&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Microsoft's architecture guidance on &lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/tenancy-models&quot;&gt;tenancy models&lt;/a&gt; describes the same trade-off between isolation, cost and operational effort, and is worth reading alongside this.&lt;/p&gt;
&lt;h3 id=&quot;how-to-choose-five-questions&quot;&gt;How to choose: five questions&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Who are your customers?&lt;/strong&gt; Many small customers point toward a shared database. A few large or regulated customers who ask about data isolation point toward separate databases.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. What are the compliance expectations?&lt;/strong&gt; If customers need contractual guarantees about data separation, or data residency in specific regions, a separate database makes those promises easier to keep.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. How uneven will tenants be?&lt;/strong&gt; If one tenant could be a hundred times bigger than the average, plan for isolating heavy tenants early.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. How big is the team?&lt;/strong&gt; Every extra database is something to monitor, migrate and back up. A small team should choose the simplest model that meets the requirements.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. How will you grow?&lt;/strong&gt; Many products start shared and move their largest customers to dedicated databases later. A design that allows this is more valuable than a perfect choice on day one.&lt;/p&gt;
&lt;h3 id=&quot;my-default-for-early-stage-saas&quot;&gt;My default for early-stage SaaS&lt;/h3&gt;
&lt;p&gt;For most early products I start with a &lt;strong&gt;shared database and a &lt;code&gt;tenant_id&lt;/code&gt; on every tenant-owned table&lt;/strong&gt;, and I build the safeguards in from day one. This keeps cost and complexity low while the product finds its market, and it does not close the door on moving large customers out later.&lt;/p&gt;
&lt;p&gt;The key is that &quot;shared&quot; must not mean &quot;careless&quot;. The safeguards I add:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A global scope or middleware&lt;/strong&gt; that applies the tenant filter automatically, so developers can't forget it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database-level protection&lt;/strong&gt; where the database supports it. PostgreSQL's &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/ddl-rowsecurity.html&quot;&gt;row-level security&lt;/a&gt; can enforce tenant filtering even if application code has a bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tenant-aware tests&lt;/strong&gt; that fail if a query returns another tenant's rows&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Composite unique keys&lt;/strong&gt; that include the tenant, so two customers can use the same email or slug&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tenant context in logs and jobs&lt;/strong&gt;, so background work never runs without knowing whose data it touches&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you use Laravel, packages such as &lt;a rel=&quot;nofollow&quot; href=&quot;https://tenancyforlaravel.com/&quot;&gt;Tenancy for Laravel&lt;/a&gt; support both single-database and multi-database approaches, which helps you start simple without being locked in.&lt;/p&gt;
&lt;h3 id=&quot;common-mistakes&quot;&gt;Common mistakes&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Forgetting background jobs.&lt;/strong&gt; Queued jobs, scheduled tasks and webhooks run outside a web request, so the tenant context can be missing. Pass the tenant explicitly, as I describe in my guide to &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/laravel-queue-architecture-saas&quot;&gt;Laravel queue architecture&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Leaking through shared resources.&lt;/strong&gt; Caches, search indexes and file storage need tenant-aware keys and paths, not only database rows. See &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/laravel-secure-file-storage-saas&quot;&gt;secure multi-tenant file storage in Laravel&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Global unique constraints.&lt;/strong&gt; Uniqueness rules that ignore the tenant make customers collide with each other.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No plan for super-admin access.&lt;/strong&gt; Support staff need to view tenant data, and that access should be controlled and logged. My article on &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/laravel-audit-logging-compliance-saas&quot;&gt;audit logging for SaaS compliance&lt;/a&gt; covers this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choosing separate databases too early.&lt;/strong&gt; It feels safer but multiplies operations for a product that may only have a handful of customers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cross-tenant reporting as an afterthought.&lt;/strong&gt; Analytics across tenants are easy in a shared schema and hard with many databases.&lt;/p&gt;
&lt;h3 id=&quot;a-real-world-example&quot;&gt;A real-world example&lt;/h3&gt;
&lt;p&gt;On Imperial Votes, a multi-tenant competition platform I built on Next.js and Prisma, organizers run their own competitions with branding tools, role-based permissions and dashboards, while super-admin controls sit above them. That mix of customers acting independently under one platform is exactly where the tenancy decision matters. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/work/imperial-votes-nextjs-saas-voting-platform-case-study&quot;&gt;Imperial Votes case study&lt;/a&gt; shows how the product grew, and the matching write-up on &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/multi-tenant-voting-saas-nextjs-prisma-case-study&quot;&gt;building a multi-tenant voting SaaS&lt;/a&gt; goes deeper.&lt;/p&gt;
&lt;h3 id=&quot;what-to-decide-before-you-write-code&quot;&gt;What to decide before you write code&lt;/h3&gt;
&lt;p&gt;Before the first migration, write down answers to these:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What is a tenant in our product (a company, a team, a workspace)?&lt;/li&gt;
&lt;li&gt;Which tables belong to a tenant, and which are global?&lt;/li&gt;
&lt;li&gt;How is the tenant identified on each request: subdomain, path or login?&lt;/li&gt;
&lt;li&gt;How will background jobs and webhooks know the tenant?&lt;/li&gt;
&lt;li&gt;What would force us to move a customer to a dedicated database?&lt;/li&gt;
&lt;li&gt;How do support staff access tenant data safely?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Answering these costs an afternoon. Changing the model after launch costs months.&lt;/p&gt;
&lt;h3 id=&quot;moving-a-large-tenant-to-its-own-database-later&quot;&gt;Moving a large tenant to its own database later&lt;/h3&gt;
&lt;p&gt;If you design for it, moving one customer out of the shared database is a manageable project rather than a rewrite. The usual steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create the new database with the same schema and run the same migrations.&lt;/li&gt;
&lt;li&gt;Copy the tenant's rows across, using the &lt;code&gt;tenant_id&lt;/code&gt; to select exactly what belongs to them.&lt;/li&gt;
&lt;li&gt;Verify counts and key records, and keep the old data until the checks pass.&lt;/li&gt;
&lt;li&gt;Switch the tenant's connection so requests resolve to the new database.&lt;/li&gt;
&lt;li&gt;Remove the tenant's rows from the shared database once you are confident.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This only works if every tenant-owned table carries the tenant identifier, files and caches are tenant-scoped, and your application resolves the database connection from the tenant. Those are the same safeguards that keep the shared model safe, which is why I add them from the start.&lt;/p&gt;
&lt;h3 id=&quot;key-takeaways&quot;&gt;Key takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Multi-tenancy is mostly about isolation and operational cost.&lt;/li&gt;
&lt;li&gt;Shared database with a tenant column is the cheapest start. Separate databases give the strongest isolation.&lt;/li&gt;
&lt;li&gt;Add safeguards such as global scopes, row-level security and tenant-aware tests so shared never means careless.&lt;/li&gt;
&lt;li&gt;Plan for jobs, caches, files and support access, not only database tables.&lt;/li&gt;
&lt;li&gt;Design so you can move large tenants out later.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are planning a SaaS product and want the architecture settled before you build, &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/book&quot;&gt;book a call&lt;/a&gt; and we can map the tenancy model to your customers and budget.&lt;/p&gt;
&lt;p&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/multi-tenant-saas-shared-vs-separate-database&quot;&gt;aqibjavaid.site&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31897/multi-tenant-saas-shared-database-or-one-per-tenant</guid>
<pubDate>Wed, 21 Oct 2026 00:03:21 +0000</pubDate>
</item>
<item>
<title>Demystifying MongoDB Write Concern and Read Concern in Node.js</title>
<link>https://coderlegion.com/31698/demystifying-mongodb-write-concern-and-read-concern-in-node-js</link>
<description>&lt;h3 id=&quot;what-are-write-concern-and-read-concern-&quot;&gt;What Are Write Concern and Read Concern?&lt;/h3&gt;
&lt;p&gt;In MongoDB, &lt;strong&gt;write concern&lt;/strong&gt; and &lt;strong&gt;read concern&lt;/strong&gt; are configuration settings that specify the level of guarantee you require when saving or retrieving data across a distributed cluster. A write concern determines how many database nodes must successfully record an update—and whether that data must be written to disk—before MongoDB reports success to your application. A read concern determines how fresh and consistent the data returned by a query must be, protecting your application from reading temporary data that might later be reversed.&lt;/p&gt;
&lt;p&gt;Together, these two settings let developers choose where their application sits on the classic spectrum between maximum execution speed and rock-solid data safety.&lt;/p&gt;
&lt;h3 id=&quot;the-real-world-analogy-certified-mail-vs-kitchen-whiteboards&quot;&gt;The Real-World Analogy: Certified Mail vs. Kitchen Whiteboards&lt;/h3&gt;
&lt;p&gt;Imagine you run a bustling catering kitchen with multiple head chefs.&lt;/p&gt;
&lt;p&gt;When taking an order, you have two choices for your &lt;strong&gt;write concern&lt;/strong&gt;. You could scribble the customer's allergy request on a loose scrap of paper and immediately yell &quot;Order placed!&quot; (a fast write, but if you drop the paper, the order is lost). Alternatively, you could walk the order ticket over to the main order board, watch two sous-chefs sign off on it, and wait for them to file it in the metal lockbox before confirming the order with the customer. The second route takes several seconds longer, but you are guaranteed that the kitchen will never forget the order even if one chef walks out.&lt;/p&gt;
&lt;p&gt;Now consider the &lt;strong&gt;read concern&lt;/strong&gt;. If a waiter needs to know the soup of the day, they can either glance at a whiteboard where someone started drafting ideas with an erasable marker, or they can refuse to serve anything until they see the official menu stamped and approved by the head kitchen manager. Glancing at the draft whiteboard is instantaneous, but you might serve an experimental soup that the manager ultimately rejects five minutes later.&lt;/p&gt;
&lt;h3 id=&quot;why-it-matters-in-daily-software-engineering&quot;&gt;Why It Matters in Daily Software Engineering&lt;/h3&gt;
&lt;p&gt;Modern web architectures rarely run on a single database server. Instead, they use replica sets—groups of connected servers where one &quot;primary&quot; node handles writes and multiple &quot;secondary&quot; nodes replicate that data for redundancy. &lt;/p&gt;
&lt;p&gt;If you leave MongoDB on default settings during high-traffic moments, subtle race conditions emerge:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Rollback Catastrophes:&lt;/strong&gt; If an API endpoint confirms an e-commerce order after writing only to the primary node, and that primary node suddenly experiences a hardware failure before syncing to secondary nodes, the cluster elects a new primary. Your customer's order is wiped out, yet your payment processor already charged their credit card.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dirty Reads:&lt;/strong&gt; A background worker updates a bank balance. A microservice queries the secondary replica before the primary finishes replicating. If that transaction aborts or fails over, the microservice has operated on data that never officially existed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By tuning write and read concerns, software engineers intentionally shield critical operations (payments, account transfers, credential updates) from data loss while letting low-risk operations (logging, view counters, user analytics) run with blazing speed.&lt;/p&gt;
&lt;h3 id=&quot;implementation-in-node-js&quot;&gt;Implementation in Node.js&lt;/h3&gt;
&lt;p&gt;Here is how you apply write and read concerns when working with the official MongoDB Node.js driver:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import { MongoClient } from 'mongodb';

const client = new MongoClient('mongodb://localhost:27017,localhost:27018/?replicaSet=rs0');

async function processPayment(orderId, paymentDetails) {
  await client.connect();
  const db = client.db('store');

  // Configure high-durability options for financial transactions
  const ordersCollection = db.collection('orders', {
    writeConcern: {
      w: 'majority',      // Must be acknowledged by more than half of the replica set nodes
      j: true,            // Must be written to the on-disk journal before confirming
      wtimeout: 5000      // Abort if replicas do not respond within 5 seconds
    },
    readConcern: {
      level: 'majority'   // Only read data committed by a majority of nodes
    }
  });

  // Step 1: Safely write with acknowledgment
  const writeResult = await ordersCollection.updateOne(
    { _id: orderId },
    { $set: { status: 'PAID', payment: paymentDetails } }
  );

  // Step 2: Read guaranteed, durable data
  const confirmedOrder = await ordersCollection.findOne({ _id: orderId });

  console.log('Payment processed safely:', confirmedOrder._id);
  return confirmedOrder;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this example, specifying &lt;code&gt;w: 'majority'&lt;/code&gt; and &lt;code&gt;j: true&lt;/code&gt; ensures that the write cannot be rolled back if the primary server abruptly crashes. Setting &lt;code&gt;level: 'majority'&lt;/code&gt; on the read ensures your application reads only finalized records.&lt;/p&gt;
&lt;h3 id=&quot;key-takeaway&quot;&gt;Key Takeaway&lt;/h3&gt;
&lt;p&gt;Database speed and data reliability exist on a continuous slider: write concern dictates how hard your cluster works to preserve your updates, while read concern dictates how strictly your cluster verifies the stability of what you fetch. Mastering both allows you to protect financial transactions and vital data without unnecessarily throttling high-volume, low-risk endpoints across your stack.&lt;/p&gt;
&lt;hr&gt;
&lt;p align=&quot;center&quot;&gt;
  &lt;a href=&quot;https://www.buymeacoffee.com/saurav_tb_pandey&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer nofollow&quot;&gt;
    &lt;img src=&quot;https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=for-the-badge&amp;amp;logo=buy-me-a-coffee&amp;amp;logoColor=black&quot; alt=&quot;Buy Me A Coffee&quot;&gt;
&lt;p&gt;  &lt;/p&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Enjoyed this breakdown? Support my open-source work on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.buymeacoffee.com/saurav_tb_pandey&quot;&gt;Buy Me a Coffee&lt;/a&gt; or explore &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/react-hook-lab&quot;&gt;react-hook-lab&lt;/a&gt;!&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/10/how-mongodb-write-concern-and-read.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31698/demystifying-mongodb-write-concern-and-read-concern-in-node-js</guid>
<pubDate>Mon, 19 Oct 2026 09:22:10 +0000</pubDate>
</item>
<item>
<title>Database Migrations: Can Your Previous Release Still Run?</title>
<link>https://coderlegion.com/31693/database-migrations-can-your-previous-release-still-run</link>
<description>&lt;p&gt;A deployment can roll back cleanly while the database stays incompatible with the old application. Before shipping a schema change, check whether the previous release can still read and write the data that the new release creates.&lt;/p&gt;
&lt;p&gt;Consider a fictional account service. Version 1 reads a column called &lt;code&gt;display_name&lt;/code&gt;. Version 2 expects &lt;code&gt;public_name&lt;/code&gt;. Renaming the column and deploying the new code together looks tidy, until an old worker, a rolling-deployment instance, or an emergency rollback still asks for &lt;code&gt;display_name&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The migration succeeded. The service can still fail.&lt;/p&gt;
&lt;h3 id=&quot;separate-compatibility-from-cleanup&quot;&gt;Separate compatibility from cleanup&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://martinfowler.com/bliki/ParallelChange.html&quot;&gt;Danilo Sato’s explanation of parallel change&lt;/a&gt; describes three stages: expand, migrate, and contract. The useful idea is to introduce the new interface while the old one remains available, move consumers, then remove the old interface.&lt;/p&gt;
&lt;p&gt;For a database rollout, turn that idea into explicit release gates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Expand:&lt;/strong&gt; introduce the new storage shape without removing what the currently deployed application needs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate:&lt;/strong&gt; move reads and writes deliberately, checking compatibility during the transition.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contract:&lt;/strong&gt; remove the old shape only after its consumers and the intended rollback window are accounted for.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These stages are a planning model, not a guarantee of zero downtime. The exact migration still depends on the database, data volume, application behavior, and operational limits.&lt;/p&gt;
&lt;h3 id=&quot;work-through-the-awkward-middle&quot;&gt;Work through the awkward middle&lt;/h3&gt;
&lt;p&gt;In the account-service example, adding &lt;code&gt;public_name&lt;/code&gt; is only the beginning. Before copying values, decide which field is authoritative at each stage.&lt;/p&gt;
&lt;p&gt;A possible transition is to deploy compatibility code that continues serving old readers while maintaining both values. If both columns live in the same row, update them in the same database transaction where practical. That still leaves an important question: can any old application instance or background job write only the original column?&lt;/p&gt;
&lt;p&gt;Do not switch reads to the new column while those writers can silently make it stale. Either finish upgrading writers first or design a deliberate synchronization mechanism. A fallback read helps with missing values; it does not detect an outdated non-empty value.&lt;/p&gt;
&lt;p&gt;Backfills need the same care. A naive copy can overwrite a newer value if it races with a live update. Define an update condition, concurrency strategy, and restart behavior before running the job. Record progress so an interrupted backfill can resume without blindly replaying the entire table.&lt;/p&gt;
&lt;h3 id=&quot;test-the-rollback-with-new-data&quot;&gt;Test the rollback with new data&lt;/h3&gt;
&lt;p&gt;A useful rehearsal includes more than deploying version 2 successfully:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start with representative data, including empty and unusual values.&lt;/li&gt;
&lt;li&gt;Apply the expansion and deploy the compatibility release.&lt;/li&gt;
&lt;li&gt;Run the backfill alongside representative writes.&lt;/li&gt;
&lt;li&gt;Create and edit records using the new release.&lt;/li&gt;
&lt;li&gt;Restore the application version you actually intend to roll back to.&lt;/li&gt;
&lt;li&gt;Verify that it can read and update those new records correctly.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Include background workers, scheduled jobs, exports, and direct database consumers in that exercise. A healthy web endpoint does not prove that every writer is compatible.&lt;/p&gt;
&lt;p&gt;Use synthetic or appropriately protected test data. This checklist proposes evidence to collect; it does not claim that a production migration has been tested.&lt;/p&gt;
&lt;h3 id=&quot;a-compatible-change-can-still-block-traffic&quot;&gt;A compatible change can still block traffic&lt;/h3&gt;
&lt;p&gt;In PostgreSQL, &lt;code&gt;ALTER TABLE&lt;/code&gt; generally acquires an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock unless the documentation specifies otherwise. Compatibility at the application level does not eliminate lock waits.&lt;/p&gt;
&lt;p&gt;PostgreSQL also supports separating certain constraint additions from validation with &lt;code&gt;NOT VALID&lt;/code&gt; and &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt;. The permitted constraint types and locking details matter. Check the documentation for your deployed version rather than applying the technique to every schema operation. See the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/sql-altertable.html&quot;&gt;PostgreSQL ALTER TABLE reference&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Before execution, define acceptable lock wait, query latency, replication lag, and backfill load. Decide who can stop the rollout and what the safe next step is if a threshold is crossed.&lt;/p&gt;
&lt;h3 id=&quot;make-cleanup-its-own-decision&quot;&gt;Make cleanup its own decision&lt;/h3&gt;
&lt;p&gt;Keep a short release note that answers four questions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which application versions can run against this schema?&lt;/li&gt;
&lt;li&gt;Which data changes can the previous version understand?&lt;/li&gt;
&lt;li&gt;What evidence allows the old column or interface to be removed?&lt;/li&gt;
&lt;li&gt;After removal, does recovery mean application rollback, a forward fix, or data restoration?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Give the cleanup an owner. Leaving parallel paths indefinitely creates its own maintenance cost, but removing them too early destroys the compatibility you built.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The practical takeaway:&lt;/strong&gt; rehearse rollback against data written by the new release before treating it as a recovery plan.&lt;/p&gt;
&lt;p&gt;What is the most easily missed database consumer in your deployment process?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Waqas Ahmad | Software Engineer | Content Creator&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31693/database-migrations-can-your-previous-release-still-run</guid>
<pubDate>Mon, 19 Oct 2026 07:45:55 +0000</pubDate>
</item>
<item>
<title>Advanced SQL Analytics for Data Analysis.</title>
<link>https://coderlegion.com/31618/advanced-sql-analytics-for-data-analysis</link>
<description>&lt;h2 id=&quot;advanced-sql-analytics-for-running-a-hospital-database-&quot;&gt;Advanced SQL analytics for running a hospital database.&lt;/h2&gt;
&lt;h3 id=&quot;introduction-1&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Structured Query Language is essential and common in most industry sectors. In healthcare reporting, SQL plays a critical role in analytics. PostgreSQL lets data scientists take those transaction records and pull out operational insights, so you can make decisions. In this guide we go through the main SQL techniques, using a hospital database schema; patients , doctors, departments , wards, diagnoses, procedures, and visits. &lt;/p&gt;
&lt;p&gt;First, before anything else we need to create our database schema. Connect to PostgerSQL DBMS and run a new sql script to start.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a schema &lt;code&gt;hospital&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;create schema hospital;&lt;/code&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check to which schema you are connected.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;show search_path;&lt;/code&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;If connected to a different schema, set connection to your &lt;code&gt;hospital&lt;/code&gt; schema.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;set search_path to hospital;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Now you are set to begin creating your tables. Run the following codes to create the different tables and insert data into the newly created tables:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Doctors Table&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;--create doctors table

create table hospital.doctors (
doctor_id int primary key,
doctor_name varchar(50) not null,
doctor_gender varchar(20),
doctor_specialty varchar(20),
doctor_experience_years int
);

select * from hospital.doctors;

-- insert data into doctors

insert into hospital.doctors (doctor_id, doctor_name, doctor_gender, doctor_specialty, doctor_experience_years)
values 
(101, 'DR.Brian Mwangi', 'Male', 'Cardiologist', 12),
(102, 'Dr. Sarah Wanjiku', 'Female', 'General Physician', 9), 
(103, 'Dr. Peter Otieno', 'Male', 'Orthopedic Surgeon', 15),
(104, 'Dr. Faith Chebet', 'Female', 'Pediatrician', 8);

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Departments Table&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- create departments table

create table hospital.department (
department_id int primary key,
department_name varchar (50) not null
);


select * from hospital.department;

-- insert data into departments

insert into hospital.department (department_id, department_name)
values 
(10, 'Cardiology'),
(20, 'General Medicine'),
(30, 'Orthopedics'),
(40, 'Pediatrics');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Departments Table&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- create diagnosis table

create table hospital.diagnosis (
diagnosis_id int primary key,
diagnosis_name varchar(50),
diagnosis_category varchar(50)
);

select * from hospital.diagnosis;

--insert data into diagnosis table

insert into hospital.diagnosis (diagnosis_id, diagnosis_name, diagnosis_category)
values 
(501,'Hypertension', 'Chronic Disease'),
(502, 'Malaria', 'Infectious Disease'),
(503, 'Fracture', 'Injury'),
(504, 'Pneumonia', 'Respiratory Disease'),
(505, 'Diabetes', 'Chronic Disease'),
(506, 'Typhoid', 'Infectious'),
(507, 'Asthma', 'Respiratory Disease'),
(508, 'Back Pian', 'Musculoskeletal'),
(509, 'Gastritis', 'Digestive Disease'),
(510, 'Heart Failure', 'Chronic Disease'),
(511, 'Flu', 'Respiratoey Disease'),
(512, 'Kidney Infection', 'Infectious Disease'),
(513, 'Anaemia', 'Blood Disorder'),
(514, 'Sprain', 'Injury'),
(515, 'Gastroenteritis', 'Digestive Disease'),
(516, 'Dislocation', 'Injury'),
(517, 'Migraine', 'Neurological Disorder'),
(518, 'Chest Pain', 'Cardiovascular Disease');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Wards Table&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- create wards table 

create table hospital.wards (
ward_id int primary key, 
ward_name varchar (50)
);

-- insert data into wards
insert into hospital.wards (ward_id, ward_name)
values 
(100, 'Cardiac Care Unit'),
(200, 'General Ward'),
(300, 'Surgical Ward'),
(400, 'Childrens Ward');

select * from hospital.wards;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Patients Table&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- create patients table 
create table hospital.patients (
patient_id serial primary key, 
patient_name varchar(50) not null,
patient_gender varchar(20),
patient_age int,
county varchar(30),
sub_county varchar(30)
);

--insert data into patients
insert into hospital.patients (patient_name, patient_gender, patient_age, county, sub_county)
values 
('Amina Otieno', 'Female', 34, 'Nairobi', 'Westlands'),
('John Kariuki', 'Male', 42,' Kiambu', 'Thika'),
('Grace Naliaka', 'Female', 28, 'Kakamega', 'Lurambi'),
('David Mutua', 'Male', 55, 'Machakos', 'Athi River'),
('Esther Achieng', 'Female', 63, 'Kisumu', 'Kisumu Central'),
('Mohamed Ali', 'Male', 19, 'Mombasa', 'Nyali'),
('Lucy Wambui', 'Female', 7, 'Nakuru', 'Naivasha'),
('Samuel Kiptoo', 'Male', 39, 'Uasin Gishu', 'Eldoret East'),
('Mary Wairumu', 'Female', 31, 'Murang''a', 'Kiharu'),
('Peter Ouma', 'Male', 46, 'Siaya', 'Bondo'),
('Brian Omondi', 'Male', 24, 'Kisumu', 'Kisumu East'),
('Catherine Musyoka', 'Female', 52, 'Kitui', 'Kitui Central'),
('Daniel Maina', 'Male', 16, 'Nyeri', 'Othaya'),
('Eunice Moraa', 'Female', 44, 'Kisii', 'Bonchari'),
('Francis Njuguna', 'Male', 60, 'Laikipia', 'Nyanyuki'),
('Hellen Jepchirchir', 'Female', 37, 'Bomet', 'Sotik'),
('Isaac Kamau', 'Male', 29, 'Nairobi', 'Kasarani'),
('Jane Muthoni', 'Female', 22, 'Meru', 'Imenti North'),
('Kevin Barasa', 'Male', 48, 'Bungoma', 'Webuye'),
('Lilian Adhiambo', 'Female', 26, 'Homa Bay', 'Rangwe'),
('Moses Cheruiyot', 'Male', 33, 'Kericho', 'Ainamoi'),
('Nadia Hussein', 'Female', 5, 'Garissa', 'Dadaab'),
('Patrick Muli', 'Male', 51, 'Makueni', 'Wote'),
('Ruth Nekesa', 'Female', 41, 'Trans Nzoia', 'Kitale'),
('Josephine Wekesa', 'Female', 36, 'Busia', 'Matayos');

select * from hospital.patients;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Procedures Table&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create table hospital.procedures (
procedure_id int primary key,
procedure_name varchar (50) not null,
procedure_category varchar (20)
);

--insert data into procedures
insert into hospital.procedures (procedure_id, procedure_name, procedure_category)
values
(701, 'Blood Pressure Monitoring', 'Diagnostic'),
(702, 'Malaria Test', 'Laboratory'),
(703, 'X-ray', 'Imaging'),
(704, 'Chest X-ray', 'Imaging'),
(705, 'Blood Sugar Test', 'Laboratory'),
(706, 'Full Blood Count', 'Laboratory'),
(707, 'Nebulization', 'Treatment'),
(708, 'Physiotherapy', 'Treatment'),
(709, 'Endoscopy', 'Diagnostic'),
(710, 'ECG', 'Diagnostic'),
(711, 'Consultation', 'Consultation'),
(712, 'Joint Support Bandage', 'Treatment'),
(713, 'IV Fluids', 'Treatment'),
(714, 'Closed Reduction', 'Treatment'),
(715, 'Pain Management', 'Treatment');


select * from hospital.procedures;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Visits Table&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- create visits table (Fact Table)

create table hospital.visits (
visit_id int primary key,
visit_date date not null,
patient_id int not null references hospital.patients(patient_id),
doctor_id int not null references hospital.doctors(doctor_id),
department_id int not null references hospital.department(department_id),
ward_id int references hospital.wards(ward_id),
diagnosis_id int references hospital.diagnosis(diagnosis_id),
procedure_id int references hospital.procedures(procedure_id),
admission_type varchar(20) not null
    check (admission_type in ('Outpatient', 'Inpatient', 'Emergency')),
length_of_stay_days int default 0
    check (length_of_stay_days &amp;gt;= 0),
triage_level varchar(10)
    check (triage_level in ('Red', 'Orange', 'Yellow', 'Green')),
insurance_provider varchar(50),
total_bill_kes numeric(10,2) not null
    check (total_bill_kes &amp;gt;= 0),
payment_method varchar(20),
follow_up_required varchar(3)
    check (follow_up_required in ('Yes', 'No')),
patient_satisfaction_score numeric(3,1)
    check (patient_satisfaction_score between 0 and 5)
);

--insert data into visits table
insert into hospital.visits (visit_id, visit_date, patient_id, doctor_id, department_id, ward_id, diagnosis_id,
procedure_id, admission_type, length_of_stay_days, triage_level, insurance_provider, total_bill_kes, payment_method,
follow_up_required, patient_satisfaction_score)
values
(1001, '2026-06-01', 1, 101, 10, 100, 501, 701, 'Outpatient', 0, 'Green', 'NHIF', 3500, 'Cash', 'Yes', 4.5),
(1002, '2026-06-01', 2, 102, 20, 200, 502, 702, 'Outpatient', 0, 'Yellow', 'Britam', 2500, 'Insurance', 'No', 4.1),
(1003, '2026-06-02', 3, 103, 30, 300, 503, 703, 'Inpatient', 3, 'Red', 'AAR', 28000, 'Insurance', 'Yes', 4.3),
(1004, '2026-06-02', 4, 104, 40, 400, 504, 704, 'Inpatient', 4, 'Red', 'None', 42000, 'Cash', 'Yes', 3.9),
(1005, '2026-06-03', 5, 101, 10, 100, 505, 705, 'Outpatient', 0, 'Yellow', 'CIC', 4000, 'Mobile Money', 'Yes', 4.7),
(1006, '2026-06-03', 6, 102, 20, 200, 506, 706, 'Outpatient', 0, 'Green', 'None', 3000, 'Cash', 'No', 4.0),
(1007, '2026-06-04', 7, 104, 40, 400, 507, 707, 'Outpatient', 0, 'Yellow', 'NHIF', 5500, 'Insurance', 'Yes', 4.6),
(1008, '2026-06-04', 8, 103, 30, 300, 508, 708, 'Outpatient', 0, 'Green', 'None', 6500, 'Cash', 'Yes', 4.2),
(1009, '2026-06-05', 9, 102, 20, 200, 509, 709, 'Outpatient', 0, 'Yellow', 'Jubilee', 15000, 'Insurance', 'No', 3.8),
(1010, '2026-06-05', 10, 101, 10, 100, 510, 710, 'Inpatient', 5, 'Red', 'AAR', 76000, 'Insurance', 'Yes', 4.4),
(1011, '2026-06-06', 1, 101, 10, 100, 505, 705, 'Outpatient', 0, 'Green', 'NHIF', 4200, 'Mobile Money', 'Yes', 4.8),
(1012, '2026-06-06', 11, 102, 20, 200, 502, 702, 'Outpatient', 0, 'Green', 'None', 2300, 'Cash', 'No', 4.0),
(1013, '2026-06-07', 12, 101, 10, 100, 501, 710, 'Outpatient', 0, 'Yellow', 'Britam', 7000, 'Insurance', 'Yes', 4.5),
(1014, '2026-06-07', 13, 104, 40, 400, 511, 711, 'Outpatient', 0, 'Green', 'None', 1800, 'Cash', 'No', 4.1),
(1015, '2026-06-08', 14, 103, 30, 300, 503, 703, 'Inpatient', 2, 'Red', 'CIC', 24000, 'Mobile Money', 'Yes', 4.6),
(1016, '2026-06-08', 15, 102, 20, 200, 512, 706, 'Inpatient', 3, 'Orange', 'Jubilee', 36000, 'Insurance', 'Yes', 3.7),
(1017, '2026-06-09', 16, 101, 10, 100, 510, 710, 'Inpatient', 6, 'Red', 'AAR', 82000, 'Insurance', 'Yes', 4.2),
(1018, '2026-06-09', 17, 102, 20, 200, 509, 711, 'Outpatient', 0, 'Green', 'None', 2000, 'Cash', 'No', 4.0),
(1019, '2026-06-10', 18, 104, 40, 400, 507, 707, 'Outpatient', 0, 'Yellow', 'NHIF', 5200, 'Mobile Money', 'Yes', 4.7),
(1020, '2026-06-10', 19, 103, 30, 300, 508, 708, 'Outpatient', 0, 'Green', 'Britam', 6500, 'Insurance', 'No', 4.1),
(1021, '2026-06-11', 2, 101, 10, 100, 501, 701, 'Outpatient', 0, 'Yellow', 'Britam', 3800, 'Insurance', 'Yes', 4.3),
(1022, '2026-06-11', 20, 102, 20, 200, 513, 706, 'Outpatient', 0, 'Green', 'None', 3200, 'Mobile Money', 'No', 4.4),
(1023, '2026-06-12', 21, 103, 30, 300, 514, 712, 'Outpatient', 0, 'Yellow', 'NHIF', 7200, 'Cash', 'Yes', 4.0),
(1024, '2026-06-12', 22, 104, 40, 400, 515, 713, 'Inpatient', 2, 'Orange', 'None', 18500, 'Cash', 'Yes', 4.5),
(1025, '2026-06-13', 5, 101, 10, 100, 501, 710, 'Outpatient', 0, 'Yellow', 'CIC', 7600, 'Insurance', 'Yes', 4.6),
(1026, '2026-06-13', 23, 102, 20, 200, 506, 706, 'Inpatient', 2, 'Orange', 'Jubilee', 29000, 'Insurance', 'Yes', 3.9),
(1027, '2026-06-14', 24, 103, 30, 300, 516, 714, 'Inpatient', 1, 'Red', 'AAR', 33000, 'Insurance', 'Yes', 4.2),
(1028, '2026-06-14', 7, 104, 40, 400, 511, 711, 'Outpatient', 0, 'Green', 'NHIF', 1900, 'Mobile Money', 'No', 4.8),
(1029, '2026-06-15', 25, 102, 20, 200, 517, 715, 'Outpatient', 0, 'Yellow', 'Britam', 4800, 'Cash', 'Yes', 4.1),
(1030, '2026-06-15', 10, 101, 10, 100, 518, 710, 'Emergency', 1, 'Red', 'AAR', 22000, 'Insurance', 'Yes', 4.4);

select * from hospital.visits;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After creating our database schema, we will begin modelling the data using different advanced SQL functions.&lt;/p&gt;
&lt;h3 id=&quot;1-sql-window-functions&quot;&gt;1. SQL Window Functions&lt;/h3&gt;
&lt;p&gt;Window functions do computations on related rows in a table while maintaining the individuality of each rows.&lt;/p&gt;
&lt;h4 id=&quot;over-partition-by-order-by-and-window-frames&quot;&gt;OVER, PARTITION BY, ORDER BY, and Window Frames&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;OVER&lt;/code&gt; clause describes the rows for the window function operation.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PARTITION BY&lt;/code&gt; defines different partitions for each result set.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ORDER BY&lt;/code&gt; defines row order in each partition.&lt;/li&gt;
&lt;li&gt;Window frames define different limits such as&lt;code&gt;ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW&lt;/code&gt; for cumulative sums and &lt;code&gt;ROWS BETWEEN 2 PRECEDING AND CURRENT ROW&lt;/code&gt; for moving averages.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;ranking-window-functions&quot;&gt;Ranking Window Functions&lt;/h4&gt;
&lt;p&gt;Ranking functions provide numeric ordering according to specified criteria.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ROW_NUMBER&lt;/code&gt; provides a distinct numeric integer value to each row within a partition.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RANK&lt;/code&gt; provides identical rank values to ties, but skips other numbers.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DENSE_RANK&lt;/code&gt; provides identical rank values to ties, but does not skip numbers.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NTILE&lt;/code&gt; divides rows in partitions into buckets of equal size.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How can hospital management rank inpatient visits by total bill within each admission type?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    v.visit_id,
    p.patient_name,
    v.admission_type,
    v.total_bill_kes,
    row_number() over (partition by v.admission_type order by v.total_bill_kes desc) as row_num,
    rank() over (partition by v.admission_type order by v.total_bill_kes desc) as rnk,
    dense_rank() over (partition by v.admission_type order by v.total_bill_kes desc) as dense_rnk,
    ntile(2) over (partition by v.admission_type order by v.total_bill_kes desc) as bill_tier
from hospital.visits v
join hospital.patients p on v.patient_id = p.patient_id
where v.admission_type = 'Inpatient'
limit 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/m1wdvqqfnf9c1tdhy7l2.png&quot; alt=&quot;Visit Report&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ROW_NUMBER&lt;/code&gt; gives values from 1 to 5. &lt;/li&gt;
&lt;li&gt;The values obtained by &lt;code&gt;RANK&lt;/code&gt; and &lt;code&gt;DENSE_RANK&lt;/code&gt; will be same as there are no duplicate bill values. &lt;/li&gt;
&lt;li&gt;NTILE assigns the best bills to Tier 1.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;value-amp-navigation-window-function&quot;&gt;Value &amp;amp; Navigation Window Function&lt;/h4&gt;
&lt;p&gt;Navigation functions retrieve values based on relative rows.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LAG&lt;/code&gt; retrieves values from previous row.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LEAD&lt;/code&gt; retrieves values from next row.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FIRST_VALUE&lt;/code&gt; gives the first value of the expression in the frame.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LAST_VALUE&lt;/code&gt; gives the last value of the expression in the frame bounds like &lt;code&gt;ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How can we monitor patient billing process through multiple hospital visits?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    v.visit_id,
    v.patient_id,
    v.visit_date,
    v.total_bill_kes,
    lag(v.total_bill_kes, 1, 0.00) over (partition by v.patient_id order by v.visit_date) as prev_bill,
    lead(v.total_bill_kes, 1, 0.00) over (partition by v.patient_id order by v.visit_date) as next_bill,
    first_value(v.total_bill_kes) over (partition by v.patient_id order by v.visit_date) as initial_bill,
    last_value(v.total_bill_kes) over (
        partition by v.patient_id 
        order by v.visit_date 
        rows between unbounded preceding and unbounded following
    ) as final_bill
from visits v
where v.patient_id in (1, 2)
order by v.patient_id, v.visit_date;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/3iqglknz6vze0tocm3o6.png&quot; alt=&quot;Patient Billing Process Monitoring&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LAG&lt;/code&gt; retrieves previous visit invoices and &lt;code&gt;LEAD&lt;/code&gt; retrieves future visit invoices.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FIRST_VALUE&lt;/code&gt; and &lt;code&gt;LAST_VALUE&lt;/code&gt; store the first and last visit invoices respectively.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;aggregate-window-functions&quot;&gt;Aggregate Window Functions&lt;/h4&gt;
&lt;p&gt;Aggregate functions &lt;code&gt;SUM&lt;/code&gt;, &lt;code&gt;AVG&lt;/code&gt;, &lt;code&gt;COUNT&lt;/code&gt;, &lt;code&gt;MIN&lt;/code&gt;, and &lt;code&gt;MAX&lt;/code&gt; calculate moving sums and moving averages when combined with &lt;code&gt;OVER&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;In what way do we determine cumulative income, rolling average billings, categories, and billings for departments on each visit?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    v.visit_id,
    v.visit_date,
    v.total_bill_kes,
    sum(v.total_bill_kes) over (order by v.visit_date, v.visit_id) as cumulative_revenue,
    avg(v.total_bill_kes) over (
        order by v.visit_date 
        rows between 2 preceding and current row
    ) as rolling_avg_bill,
    count(*) over (partition by v.admission_type) as total_admission_type_count,
    min(v.total_bill_kes) over (partition by v.admission_type) as min_admission_bill,
    max(v.total_bill_kes) over (partition by v.admission_type) as max_admission_bill
from hospital.visits v
order by v.visit_date, v.visit_id
limit 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ft1sqrdykuhpi7j0ehnv.png&quot; alt=&quot;Aggregate Window Functions&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SUM&lt;/code&gt; forms a running total. &lt;/li&gt;
&lt;li&gt;&lt;code&gt;AVG&lt;/code&gt; calculates a 3 row moving average. &lt;/li&gt;
&lt;li&gt;&lt;code&gt;COUNT&lt;/code&gt;, &lt;code&gt;MIN&lt;/code&gt;, and &lt;code&gt;MAX&lt;/code&gt; present group statistics without collapsing rows.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;2-sql-joins&quot;&gt;2. SQL Joins&lt;/h3&gt;
&lt;p&gt;Joins combine columns from multiple tables using related key columns.&lt;/p&gt;
&lt;h4 id=&quot;inner-join&quot;&gt;INNER JOIN&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;INNER JOIN&lt;/code&gt; returns rows with matching key values in both tables.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How do we generate a visit report combining patient names, attending doctors, and clinical departments?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    v.visit_id,
    p.patient_name,
    d.doctor_name,
    dept.department_name,
    v.total_bill_kes
from hospital.visits v
inner join hospital.patients p on v.patient_id = p.patient_id
inner join hospital.doctors d on v.doctor_id = d.doctor_id
inner join hospital.department dept on v.department_id = dept.department_id
limit 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/cpriilp4a3qc6w9wh6v5.png&quot; alt=&quot;Inner Join&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;INNER JOIN&lt;/code&gt; Matches keys across visits, patients, doctors, and department tables.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;left-join&quot;&gt;LEFT JOIN&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;LEFT JOIN&lt;/code&gt; produces all the rows from the left table and the matched rows from the right table. Fields in the right table without a match produce &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How can we study the number of visits and expenses of patients without losing those that have no visits at all?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    p.patient_id,
    p.patient_name,
    count(v.visit_id) as total_visits,
    coalesce(sum(v.total_bill_kes), 0.00) as total_spent_kes
from hospital.patients p
left join hospital.visits v on p.patient_id = v.patient_id
group by p.patient_id, p.patient_name
order by total_visits desc, p.patient_id
limit 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/8osejvnidi5913phipk8.png&quot; alt=&quot;Left Join&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All patient records are retained using &lt;code&gt;LEFT JOIN&lt;/code&gt;. &lt;/li&gt;
&lt;li&gt;Patients having no visits will have &lt;code&gt;COUNT&lt;/code&gt; as 0 while &lt;code&gt;COALESCE&lt;/code&gt; will convert &lt;code&gt;NULL&lt;/code&gt; sums to &lt;code&gt;0.00.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;right-join&quot;&gt;RIGHT JOIN&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;RIGHT JOIN&lt;/code&gt; retrieves all records from right table and matching records from left table.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How can hospital administration ensure that all physical wards are reflected in admission records?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    w.ward_id,
    w.ward_name,
    v.visit_id,
    v.total_bill_kes
from hospital.visits v
right join hospital.wards w on v.ward_id = w.ward_id
where v.visit_id is null or v.visit_id &amp;lt;= 1003;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/trk5b98a06x3pfebvxub.png&quot; alt=&quot;Right Join&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All wards are kept in &lt;code&gt;RIGHT JOIN&lt;/code&gt;. &lt;/li&gt;
&lt;li&gt;The Childrens Ward will be with &lt;code&gt;NULL&lt;/code&gt; visits if no match occurs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;full-outer-join&quot;&gt;FULL OUTER JOIN&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;FULL OUTER JOIN&lt;/code&gt; will display all records if there is a match in either left or right record.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How do we perform auditing of doctor and department records for unassigned doctors or unmapped departments?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    d.doctor_name,
    dept.department_name
from hospital.doctors d
full outer join hospital.department dept on d.doctor_id = dept.department_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/lxx54jj6rrte3qgctuzv.png&quot; alt=&quot;Full Outer Join&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Null values will be returned in the rows where there is no relationship between doctor and department IDs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;cross-join&quot;&gt;CROSS JOIN&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;CROSS JOIN&lt;/code&gt; results in a Cartesian product where each row in the first table is paired up with each row of the second table.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How can hospital managers get a complete list of all pairings of physicians with hospital wards for coverage planing?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    d.doctor_name,
    w.ward_name
from hospital.doctors d
cross join hospital.wards w
order by d.doctor_name, w.ward_name
limit 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/1wr0ga53xrcehptw5ysf.png&quot; alt=&quot;Cross Join&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Joining 4 doctors with 4 wards results in 16 combination rows.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;self-join&quot;&gt;SELF JOIN&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;SELF JOIN&lt;/code&gt; involves joining a table to itself for comparing rows in the same table.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;In what way can readmission of patients be identified by matching their first visit to their next visit?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    v1.patient_id,
    v1.visit_id as original_visit,
    v1.visit_date as original_date,
    v2.visit_id as return_visit,
    v2.visit_date as return_date
from hospital.visits v1
inner join hospital.visits v2 on v1.patient_id = v2.patient_id and v1.visit_id &amp;lt; v2.visit_id
order by v1.patient_id
limit 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/6gu6l8j459l4c0l89mg4.png&quot; alt=&quot;Self Join&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Linking visits on patient ID where &lt;code&gt;v1.visit_id&lt;/code&gt; is less than &lt;code&gt;v2.visit_id&lt;/code&gt; matches distinct visits per patient.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;join-summary&quot;&gt;Join Summary&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;INNER JOIN&lt;/code&gt; matches rows in both tables.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LEFT JOIN&lt;/code&gt; keeps all left table rows.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RIGHT JOIN&lt;/code&gt; keeps all right table rows.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FULL OUTER JOIN&lt;/code&gt; keeps all rows from both tables.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CROSS JOIN&lt;/code&gt; produces Cartesian pairs.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SELF JOIN&lt;/code&gt; compares rows within one table.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;3-common-table-expressions-ctes-&quot;&gt;3. Common Table Expressions (CTEs)&lt;/h3&gt;
&lt;p&gt;A CTE is a named temporary table created within a &lt;code&gt;WITH&lt;/code&gt; clause.&lt;/p&gt;
&lt;h4 id=&quot;simple-ctes&quot;&gt;Simple CTEs&lt;/h4&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;What approach should be used to identify costly inpatient treatments and connect them with patients and their physicians?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;with high_cost_visits as (
    select 
        visit_id,
        patient_id,
        doctor_id,
        total_bill_kes,
        admission_type
    from hospital.visits
    where total_bill_kes &amp;gt;= 35000.00
)
select 
    h.visit_id,
    p.patient_name,
    d.doctor_name,
    h.total_bill_kes
from high_cost_visits h
join hospital.patients p on h.patient_id = p.patient_id
join hospital.doctors d on h.doctor_id = d.doctor_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/lx3qdtqcxagxsp5vwmxv.png&quot; alt=&quot;Simple CTE&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CTE will filter out high cost visits first to keep the subsequent join clean.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;multiple-chained-ctes&quot;&gt;Multiple Chained CTEs&lt;/h4&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How do we find out department revenues, overall average, and top performing departments?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;with dept_revenue as (
    select 
        department_id,
        sum(total_bill_kes) as total_revenue,
        count(visit_id) as total_visits
    from hospital.visits
    group by department_id
),
dept_benchmark as (
    select 
        avg(total_revenue) as benchmark_avg_revenue
    from dept_revenue
)
select 
    d.department_name,
    dr.total_revenue,
    dr.total_visits,
    round(db.benchmark_avg_revenue, 2) as benchmark_avg
from dept_revenue dr
join hospital.department d on dr.department_id = d.department_id
cross join dept_benchmark db
where dr.total_revenue &amp;gt; db.benchmark_avg_revenue;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/449qhx3hzealxl7t6ncw.png&quot; alt=&quot;Multiple CTEs&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dept_revenue&lt;/code&gt; calculates the total revenue for each &lt;code&gt;department&lt;/code&gt;, &lt;code&gt;dept_benchmark&lt;/code&gt; calculates the average and the last &lt;code&gt;SELECT&lt;/code&gt; statement filters departments whose revenues are greater than the average.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;recursive-ctes&quot;&gt;Recursive CTEs&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Recursive CTEs&lt;/code&gt; create a sequence by means of an &lt;code&gt;anchor query&lt;/code&gt; and &lt;code&gt;recursive query&lt;/code&gt; linked by &lt;code&gt;UNION ALL&lt;/code&gt; operator.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How can the hospital management create a biweekly follow up schedule?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;with recursive follow_up_schedule as (
    select 
        1 as cycle_number,
        cast('2026/07/01' as date) as scheduled_date
    union all
    select 
        cycle_number + 1,
        cast(scheduled_date + interval '14 days' as date)
    from follow_up_schedule
    where cycle_number &amp;lt; 4
)
select * from follow_up_schedule;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/w9avtbp5ixud7pfq9ysy.png&quot; alt=&quot;Recursive CTE&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cycle number 1 starts from &lt;code&gt;July 1 2026&lt;/code&gt; as the &lt;code&gt;anchor date&lt;/code&gt;, and the recursive query keeps adding 14 days till cycle number is 4.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;ctes-vs-subqueries&quot;&gt;CTEs vs. Subqueries&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;CTEs&lt;/code&gt; provide well-defined top-level modules, which can be used again and again, while subqueries within the WHERE or FROM clause tend to complicate code and cannot be executed recursively.&lt;/p&gt;
&lt;h3 id=&quot;4-sql-subqueries&quot;&gt;4. SQL Subqueries&lt;/h3&gt;
&lt;p&gt;A subquery is a query nested inside another SQL query.&lt;/p&gt;
&lt;h4 id=&quot;subquery-types&quot;&gt;Subquery Types&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Scalar&lt;/code&gt; Subquery fetches one row and one column.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Non-correlated&lt;/code&gt;Subquery executes only once and independently.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Correlated&lt;/code&gt; Subquery uses columns of outer query and executes for every row of outer query.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Nested&lt;/code&gt; Subquery uses subqueries in subqueries.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;subqueries-in-select-clauses&quot;&gt;Subqueries in SELECT Clauses&lt;/h4&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How can individual patient bills be shown along with the total hospital average bill?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    visit_id,
    total_bill_kes,
    (select round(avg(total_bill_kes), 2) from hospital.visits) as overall_avg_bill
from hospital.visits
where visit_id between 1001 and 1005;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/gx3q0v7r1kr5sjyfeasm.png&quot; alt=&quot;Select Subqueries&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Scalar subquery evaluates to average &lt;code&gt;16266.67&lt;/code&gt; for all rows.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;using-subqueries-in-from-clauses-derived-table-&quot;&gt;Using Subqueries in FROM Clauses (Derived Table)&lt;/h4&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How do we query the pre-aggregated metrics of patients using an inline derived table?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    patient_summary.patient_id,
    patient_summary.visit_count,
    patient_summary.total_spent
from (
    select 
        patient_id,
        count(visit_id) as visit_count,
        sum(total_bill_kes) as total_spent
    from hospital.visits
    group by patient_id
) as patient_summary
where patient_summary.visit_count &amp;gt; 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/gpg7zu85w5vr1pebl4ap.png&quot; alt=&quot;From Subquery&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;FROM&lt;/code&gt; subquery groups the number of visits per patient into table alias patient_summary, subject to WHERE.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;subqueries-in-where-clause&quot;&gt;Subqueries in WHERE Clause&lt;/h4&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;What is the correlated subquery to find visits where the total bill is greater than the average bill of that particular department?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    v1.visit_id,
    v1.patient_id,
    v1.department_id,
    v1.total_bill_kes
from hospital.visits v1
where v1.total_bill_kes &amp;gt; (
    select avg(v2.total_bill_kes)
    from hospital.visits v2
    where v2.department_id = v1.department_id
)
limit 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/08o8bu15t93paib73o1b.png&quot; alt=&quot;Where Subqueries&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The inner query calculates the average billing amount for each department based on outer query department IDs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;Which physicians have admitted any patients as inpatients?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    d.doctor_id,
    d.doctor_name,
    d.doctor_specialty
from hospital.doctors d
where exists (
    select 1 
    from hospital.visits v 
    where v.doctor_id = d.doctor_id 
      and v.admission_type = 'inpatient'
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/x34b2dlyj5gbu387srum.png&quot; alt=&quot;Exists&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;EXISTS&lt;/code&gt; returns true when there is a row for a visit that matches.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Question:&lt;/em&gt; &lt;em&gt;How can we find visits where the billing value is greater than all the outpatient visit bills?&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 
    visit_id,
    patient_id,
    total_bill_kes,
    admission_type
from hospital.visits
where total_bill_kes &amp;gt; all (
    select total_bill_kes
    from hospital.visits
    where admission_type = 'outpatient'
)
limit 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Expected Results&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/vdfcjvi0dr5ifpb94esd.png&quot; alt=&quot;where&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Explanation&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The internal query locates outpatient charges up to &lt;code&gt;15000.00&lt;/code&gt;. ALL filters visits more than &lt;code&gt;15000.00&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Understanding the concepts of window functions, joins, common table expressions, and subqueries helps in creating strong PostgreSQL queries that enable healthcare workers to review their operations and enhance financial efficiency. Such information enables healthcare providers to offer quality pateint care which meets patient outcomes. &lt;/p&gt;
&lt;h3 id=&quot;github&quot;&gt;GitHub&lt;/h3&gt;
&lt;p&gt;For the completed project visit my GitHub project &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MakoriNyachaki/sql_for_data_science&quot;&gt;Hospital Database&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31618/advanced-sql-analytics-for-data-analysis</guid>
<pubDate>Sun, 18 Oct 2026 13:54:35 +0000</pubDate>
</item>
<item>
<title>How to Back Up and Restore Self-Hosted n8n</title>
<link>https://coderlegion.com/25673/how-to-back-up-and-restore-self-hosted-n8n</link>
<description>
&lt;p&gt;
        A recoverable self-hosted n8n backup has four core parts: the database, the original encryption key, the deployment configuration and secrets, and any binary-data storage your executions still need. Back up the persistent &lt;code&gt;.n8n&lt;/code&gt; volume even when PostgreSQL stores the main database. n8n documents that this directory can still contain the encryption key, logs, and source-control assets.
      &lt;/p&gt;

      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Partner disclosure.&lt;/strong&gt; This guide includes a partner link to &lt;a href=&quot;https://n8n.partnerlinks.io/6mg2xng7s1m6&quot; target=&quot;_blank&quot; rel=&quot;sponsored noopener nofollow&quot;&gt;n8n Cloud&lt;/a&gt;. If you sign up through it, I may earn a commission at no extra cost to you.&lt;/p&gt;
      &lt;/div&gt;

      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; workflow JSON files alone are not enough. They do not recreate the complete instance state, and encrypted credentials are unusable without the key that encrypted them.&lt;/p&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;what-must-be-in-the-backup&quot;&gt;What must be in the backup&lt;/h2&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Asset&lt;/th&gt;
              &lt;th&gt;Why it matters&lt;/th&gt;
              &lt;th&gt;Typical location&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Database&lt;/td&gt;
              &lt;td&gt;Workflows, encrypted credentials, users, projects, settings, and retained execution data&lt;/td&gt;
              &lt;td&gt;&lt;code&gt;~/.n8n/database.sqlite&lt;/code&gt; or PostgreSQL&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Encryption key&lt;/td&gt;
              &lt;td&gt;Decrypts the credentials stored in the database&lt;/td&gt;
              &lt;td&gt;&lt;code&gt;~/.n8n&lt;/code&gt; settings or &lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt;&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Deployment config&lt;/td&gt;
              &lt;td&gt;Restores database connection, public URL, proxy, timezone, pruning, and execution behavior&lt;/td&gt;
              &lt;td&gt;Compose file, environment settings, secret references, proxy config&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Binary data&lt;/td&gt;
              &lt;td&gt;Restores retained files handled by executions when filesystem or external storage is used&lt;/td&gt;
              &lt;td&gt;Persistent filesystem, database, or configured external store&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Custom and community nodes&lt;/td&gt;
              &lt;td&gt;Allows restored workflows to load the same node types and versions&lt;/td&gt;
              &lt;td&gt;Package list, custom-node directory, container image, or build manifest&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
      &lt;p&gt;
        Do not put the raw encryption key, database password, OAuth secrets, or an unredacted environment file in a public repository. Keep a sanitized deployment definition in version control and protect the actual secrets in a restricted password manager, secret store, or encrypted backup.
      &lt;/p&gt;

      &lt;h2 id=&quot;choose-the-database-path-before-writing-the-runbook&quot;&gt;Choose the database path before writing the runbook&lt;/h2&gt;
      &lt;p&gt;
        Self-hosted n8n uses SQLite by default. The database file is &lt;code&gt;~/.n8n/database.sqlite&lt;/code&gt;. PostgreSQL is the other supported database option. The backup and restore commands must match the database your instance actually uses.
      &lt;/p&gt;

      &lt;h3&gt;SQLite: capture a consistent persistent volume&lt;/h3&gt;
      &lt;p&gt;
        The simplest small-instance method is a short maintenance window: stop the n8n application, archive its persistent volume, then start it again. Stopping writes avoids treating an arbitrary live file copy as a consistent database backup. For the standard Docker volume shown in n8n's installation guide, a generic sequence looks like this:
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;docker compose stop n8n
docker run --rm \
  -v n8n_data:/data:ro \
  -v &quot;$PWD/backups:/backup&quot; \
  alpine tar czf /backup/n8n-data-2026-07-18.tgz -C /data .
docker compose start n8n&lt;/pre&gt;
      &lt;p&gt;
        Replace the service and volume names with the names in your deployment. If downtime is not acceptable, use a storage snapshot or SQLite-aware backup process that guarantees a consistent result. Do not assume that copying &lt;code&gt;database.sqlite&lt;/code&gt; while executions are writing to it is safe.
      &lt;/p&gt;

      &lt;h3&gt;PostgreSQL: use a database dump&lt;/h3&gt;
      &lt;p&gt;
        PostgreSQL's official documentation recommends &lt;code&gt;pg_dump&lt;/code&gt; for logical backups. A custom-format dump works with &lt;code&gt;pg_restore&lt;/code&gt; and is practical for restoring into a fresh database. Adapt the connection values and run the tool from a host or database container that can reach PostgreSQL:
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;pg_dump --format=custom --file=n8n-2026-07-18.dump n8n&lt;/pre&gt;
      &lt;p&gt;
        The dump does not replace the n8n persistent volume or explicit encryption key. It also does not capture the Compose file, environment settings, proxy configuration, or filesystem binary data. Store those as separate parts of the same dated backup set.
      &lt;/p&gt;

      &lt;h2 id=&quot;add-native-n8n-exports-as-a-second-recovery-layer&quot;&gt;Add native n8n exports as a second recovery layer&lt;/h2&gt;
      &lt;p&gt;
        n8n's Server CLI can export all database entity types. Its documentation positions this tooling for backups and migrations, including moves between SQLite and PostgreSQL. Execution-history data tables are excluded by default because they can be large, and can be included explicitly when your retention requirement needs them.
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;docker exec -u node n8n \
  n8n export:entities \
  --outputDir=/home/node/.n8n/cli-backup&lt;/pre&gt;
      &lt;p&gt;
        Workflow and credential exports are also useful for selective recovery or versioned copies:
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;n8n export:workflow --backup --output=backups/workflows/
n8n export:credentials --backup --output=backups/credentials/&lt;/pre&gt;
      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Avoid decrypted credential exports for routine backups.&lt;/strong&gt; n8n supports a &lt;code&gt;--decrypted&lt;/code&gt; flag for migrations to a different secret key, but the resulting files expose every sensitive value in plain text. If that exceptional path is necessary, isolate, encrypt, restrict, and delete the files after the migration.&lt;/p&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;a-safe-restore-runbook&quot;&gt;A safe restore runbook&lt;/h2&gt;
      &lt;p&gt;
        First restore into an isolated instance with no production webhooks, schedules, email sends, payment calls, or CRM writes. The first recovery test should never overwrite the only production copy.
      &lt;/p&gt;
      &lt;ol&gt;
        &lt;li&gt;&lt;strong&gt;Record the target.&lt;/strong&gt; Use the same n8n version first, the same database type, and compatible custom-node versions. Upgrade only after the restored instance works.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Create fresh infrastructure.&lt;/strong&gt; Prepare a new Docker volume or empty PostgreSQL database rather than clearing production.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Restore the original encryption key.&lt;/strong&gt; Put the protected &lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt; or original n8n settings in place before n8n reads the restored credentials.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Restore the database.&lt;/strong&gt; Extract the stopped SQLite volume archive into the new volume, or restore the PostgreSQL dump into the new database.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Restore the surrounding state.&lt;/strong&gt; Reapply deployment settings, binary storage, custom nodes, public URL settings, and reverse-proxy configuration.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Start without production traffic.&lt;/strong&gt; Keep DNS, proxy routing, and outbound side effects isolated while checking startup and migration logs.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Verify the application.&lt;/strong&gt; Confirm login, projects, workflows, credentials, node availability, execution history required by policy, and binary files that should still exist.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Run one safe test.&lt;/strong&gt; Use test credentials or a non-destructive workflow and confirm that a credential can decrypt and authenticate.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Publish deliberately.&lt;/strong&gt; Review which workflows should be published before switching traffic. Imported workflows can have different active-state behavior depending on the import path and deployment mode.&lt;/li&gt;
      &lt;/ol&gt;

      &lt;h3&gt;Restore a SQLite archive into a new volume&lt;/h3&gt;
      &lt;p&gt;
        A new volume keeps the original recoverable while you inspect the restore. This example extracts the archive created above without deleting the production volume:
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;docker volume create n8n_restore_data
docker run --rm \
  -v n8n_restore_data:/data \
  -v &quot;$PWD/backups:/backup:ro&quot; \
  alpine tar xzf /backup/n8n-data-2026-07-18.tgz -C /data&lt;/pre&gt;
      &lt;p&gt;
        Point a separate restore Compose file at &lt;code&gt;n8n_restore_data&lt;/code&gt;. Do not bind it to the production hostname or webhook route until the checklist passes.
      &lt;/p&gt;

      &lt;h3&gt;Restore PostgreSQL into a fresh database&lt;/h3&gt;
      &lt;pre class=&quot;article-code&quot;&gt;createdb n8n_restore
pg_restore --dbname=n8n_restore n8n-2026-07-18.dump&lt;/pre&gt;
      &lt;p&gt;
        Real deployments may need explicit host, user, role, ownership, TLS, and schema options. Test the exact command your operator will use and document it beside the backup job without embedding the password.
      &lt;/p&gt;

      &lt;h2 id=&quot;restore-test-checklist&quot;&gt;Restore-test checklist&lt;/h2&gt;
      &lt;ul&gt;
        &lt;li&gt;The backup job has a timestamp, size, success state, and failure alert.&lt;/li&gt;
        &lt;li&gt;The database and encryption key come from the same recoverable setup.&lt;/li&gt;
        &lt;li&gt;The restore starts on a separate hostname, volume, and database.&lt;/li&gt;
        &lt;li&gt;Credential nodes open without decryption errors and one test authentication succeeds.&lt;/li&gt;
        &lt;li&gt;Required custom or community nodes load at the expected versions.&lt;/li&gt;
        &lt;li&gt;Published workflows, schedules, and webhook paths are reviewed before traffic moves.&lt;/li&gt;
        &lt;li&gt;Required retained binary files can be opened.&lt;/li&gt;
        &lt;li&gt;The restore time and missing manual steps are recorded for the next test.&lt;/li&gt;
      &lt;/ul&gt;

      &lt;h2 id=&quot;common-backup-failures&quot;&gt;Common backup failures&lt;/h2&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Mistake&lt;/th&gt;
              &lt;th&gt;What breaks during recovery&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Only exporting workflow JSON&lt;/td&gt;
              &lt;td&gt;Users, projects, settings, retained executions, and complete credential state are not restored as one instance&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Database without the original key&lt;/td&gt;
              &lt;td&gt;Credentials remain encrypted but cannot be used&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Copying a live SQLite file casually&lt;/td&gt;
              &lt;td&gt;The backup may not represent one consistent database state&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Skipping binary storage&lt;/td&gt;
              &lt;td&gt;Execution records may exist while retained documents or images are missing&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Restoring straight over production&lt;/td&gt;
              &lt;td&gt;A bad archive or wrong configuration can remove the working recovery path&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Never testing the restore&lt;/td&gt;
              &lt;td&gt;Missing keys, permissions, node packages, and undocumented steps appear during the incident&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;common-questions&quot;&gt;Common questions&lt;/h2&gt;
      &lt;h3&gt;Where does Docker store self-hosted n8n data?&lt;/h3&gt;
      &lt;p&gt;
        n8n's standard Docker example mounts a named &lt;code&gt;n8n_data&lt;/code&gt; volume at &lt;code&gt;/home/node/.n8n&lt;/code&gt;. Your Compose file may use a different volume or bind mount, so inspect the deployment rather than assuming the example name.
      &lt;/p&gt;

      &lt;h3&gt;Does exporting workflows back up credentials?&lt;/h3&gt;
      &lt;p&gt;
        Not by itself. n8n provides separate workflow and credential export commands, plus the broader &lt;code&gt;export:entities&lt;/code&gt; command. For same-instance disaster recovery, the database, original encryption key, config, and storage remain the stronger complete backup set.
      &lt;/p&gt;

      &lt;h3&gt;Can I restore into a newer n8n version?&lt;/h3&gt;
      &lt;p&gt;
        Restore the recorded version first when possible. This separates backup problems from application migrations. After the restored instance passes its checks, read the intervening release notes, take another backup, and perform the upgrade as a separate controlled change.
      &lt;/p&gt;

      &lt;h2 id=&quot;sources-checked&quot;&gt;Sources checked&lt;/h2&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/hosting/installation/docker/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Docker persistence and PostgreSQL guidance&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/hosting/configuration/supported-databases-settings/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n supported databases and SQLite location&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/hosting/configuration/configuration-examples/encryption-key/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n encryption-key behavior&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/hosting/cli-commands/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Server CLI export and import commands&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/hosting/scaling/binary-data/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n binary-data storage modes&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/backup-dump.html&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;PostgreSQL SQL dump documentation&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://sqlite.org/backup.html&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;SQLite online backup documentation&lt;/a&gt;&lt;/li&gt;
      &lt;/ul&gt;

      &lt;div class=&quot;article-referral-card&quot;&gt;
        &lt;div&gt;
          &lt;p class=&quot;article-referral-kicker mono&quot;&gt;n8n partner link&lt;/p&gt;
          &lt;p class=&quot;cta-h&quot;&gt;Prefer a managed recovery path?&lt;/p&gt;
          &lt;p&gt;I may earn a commission if you sign up through this link, at no extra cost to you.&lt;/p&gt;
        &lt;/div&gt;
        &lt;a href=&quot;https://n8n.partnerlinks.io/6mg2xng7s1m6&quot; target=&quot;_blank&quot; rel=&quot;sponsored noopener nofollow&quot; class=&quot;btn btn-primary article-referral-button&quot;&gt;Try n8n Cloud&lt;/a&gt;
      &lt;/div&gt;

      &lt;nav class=&quot;article-related&quot; aria-label=&quot;Related reading&quot;&gt;
        &lt;p class=&quot;article-related-h mono&quot;&gt;Keep reading&lt;/p&gt;
        &lt;ul&gt;
          &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/cases/self-hosted-n8n-deployment-docker-cloudflare&quot;&gt;Self-Hosted n8n with Docker, PostgreSQL, and Cloudflare&lt;/a&gt;&lt;/li&gt;
          &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-cloud-vs-self-hosted-cost&quot;&gt;n8n Cloud vs Self-Hosted: The Real Cost&lt;/a&gt;&lt;/li&gt;
          &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-error-workflow-telegram-alerts&quot;&gt;Protect n8n Workflows with Error Alerts&lt;/a&gt;&lt;/li&gt;
          &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/cases/website-uptime-monitoring-n8n&quot;&gt;Website Uptime Monitoring with n8n&lt;/a&gt;&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/nav&gt;

      &lt;div class=&quot;article-cta&quot;&gt;
        &lt;p class=&quot;cta-h&quot;&gt;Need the recovery path documented?&lt;/p&gt;
        &lt;p&gt;Send the database type, deployment shape, storage locations, and recovery target. I will help map a backup set and an isolated restore test without exposing production secrets.&lt;/p&gt;
        &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/#contact&quot; class=&quot;btn btn-primary article-cta-button&quot;&gt;Map the restore&lt;/a&gt;
      &lt;/div&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/backup-restore-self-hosted-n8n&quot;&gt;FloxoLab&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/25673/how-to-back-up-and-restore-self-hosted-n8n</guid>
<pubDate>Mon, 12 Oct 2026 15:50:00 +0000</pubDate>
</item>
<item>
<title>Caching Strategies Demystified: Cache-Aside, Write-Through, and Write-Behind</title>
<link>https://coderlegion.com/30441/caching-strategies-demystified-cache-aside-write-through-and-write-behind</link>
<description>&lt;p&gt;In software development, caching is the practice of storing frequently used data in a high-speed, temporary storage layer so future requests can be served faster. &lt;strong&gt;Caching strategies&lt;/strong&gt; are the architectural patterns that dictate how your application coordinates the movement of data between this high-speed temporary memory (the cache) and your permanent, slower storage system (the database). Choosing the right strategy ensures your application stays incredibly fast while keeping data accurate and consistent.&lt;/p&gt;
&lt;h4 id=&quot;the-notebook-and-the-library-analogy&quot;&gt;The Notebook and the Library Analogy&lt;/h4&gt;
&lt;p&gt;Imagine you are a researcher studying in a massive university library (the database) containing millions of books. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cache-Aside (Lazy Loading):&lt;/strong&gt; You keep a blank personal notebook (the cache) on your desk. When you need a fact, you look in your notebook first. If it is there (a cache hit), you read it instantly. If not (a cache miss), you walk into the library archives, search the shelves, find the fact, write it down in your notebook, and then read it. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write-Through:&lt;/strong&gt; Every time you discover a new piece of information, you write it in your personal notebook and immediately send a copy to the library's archiving desk to ensure both records are updated simultaneously before you proceed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write-Behind (Write-Back):&lt;/strong&gt; When you are brainstorming rapidly, you scribble your notes exclusively in your notebook. At the end of the day, you hand your notebook to a library assistant who updates the main archives in the background while you sleep.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;why-caching-strategies-matter-in-the-tech-industry&quot;&gt;Why Caching Strategies Matter in the Tech Industry&lt;/h4&gt;
&lt;p&gt;Without these strategies, modern platforms would collapse. If a viral social media post forces millions of users to fetch the same user profile directly from a database, the database will exhaust its connection pool, slow down, and eventually crash. &lt;/p&gt;
&lt;p&gt;Software engineers use these patterns strategically to balance speed and data integrity:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cache-Aside&lt;/strong&gt; prevents databases from being choked by heavy read traffic while keeping implementation simple.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write-Through&lt;/strong&gt; avoids data discrepancies, ensuring that critical data (like bank account balances) is never out of sync between the memory and the disk.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write-Behind&lt;/strong&gt; is utilized in write-heavy systems (like logging user activity or IoT sensor tracking) to group thousands of database writes into batch updates, protecting disk-based storage from wearing out or bottlenecking.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;implementing-cache-aside-in-node-js-amp-express&quot;&gt;Implementing Cache-Aside in Node.js &amp;amp; Express&lt;/h4&gt;
&lt;p&gt;Here is how you can implement a standard &lt;strong&gt;Cache-Aside&lt;/strong&gt; strategy using Express.js and MySQL. In this scenario, we check an in-memory cache object before querying our relational database.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const express = require('express');
const mysql = require('mysql2/promise');
const app = express();

// Mocking our fast memory cache (e.g., Redis)
const memoryCache = new Map();

// MySQL connection pool configuration
const dbPool = mysql.createPool({
  host: 'localhost',
  user: 'root',
  database: 'production_db'
});

// Route utilizing Cache-Aside
app.get('/api/users/:id', async (req, res) =&amp;gt; {
  const userId = req.params.id;
  const cacheKey = `user:${userId}`;

  try {
    // 1. Check if the data exists in the high-speed cache
    if (memoryCache.has(cacheKey)) {
      console.log('Cache Hit! Serving from memory.');
      return res.json(memoryCache.get(cacheKey));
    }

    // 2. Cache Miss: Retrieve data from the slow MySQL database
    console.log('Cache Miss. Fetching from MySQL database...');
    const [rows] = await dbPool.query('SELECT id, name, email FROM users WHERE id = ?', [userId]);

    if (rows.length === 0) {
      return res.status(404).json({ error: 'User not found' });
    }

    const userData = rows[0];

    // 3. Write data to the cache so the next request is instant
    memoryCache.set(cacheKey, userData);

    return res.json(userData);
  } catch (error) {
    return res.status(500).json({ error: 'Internal Server Error' });
  }
});

app.listen(3000, () =&amp;gt; console.log('Server running on port 3000'));
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;key-takeaway-1&quot;&gt;Key Takeaway&lt;/h4&gt;
&lt;p&gt;Caching is not a one-size-fits-all solution; it is a careful compromise between performance and accuracy. While caching can make your application feel instantaneous, selecting the wrong update strategy can lead to stale data displays or lost records, making it vital to match your architectural pattern to your application's specific read-and-write ratios.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/10/how-to-scale-your-database-guide-to.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30441/caching-strategies-demystified-cache-aside-write-through-and-write-behind</guid>
<pubDate>Fri, 09 Oct 2026 10:55:06 +0000</pubDate>
</item>
<item>
<title>Instant at 10 Million Rows, Dead at a Billion: A Decision Framework for Billion-Row SQL</title>
<link>https://coderlegion.com/30403/instant-at-10-million-rows-dead-at-a-billion-a-decision-framework-for-billion-row-sql</link>
<description>&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/billion-row-sql-hero.png?v=2&quot; alt=&quot;Abstract brand-toned data pathway backdrop for the billion-row SQL decision framework.&quot;&gt;&lt;/p&gt;
&lt;p&gt;A query that returns in 40 milliseconds on ten million rows can time out completely on a billion. Same SQL, same schema, same database. I've watched a lot of teams hit that wall and go hunting for the one fix: a cleverer index, a bigger instance, whatever database was trending that month. There isn't one fix. There are two questions that decide almost everything. What engine class is this workload on — row-store, columnar, or distributed? And does the query's working set fit in memory, or does it spill to disk? Answer those honestly and most &quot;billion-row problems&quot; quietly resolve. Answer them wrong and no amount of tuning bails you out.&lt;/p&gt;
&lt;h3 id=&quot;the-query-that-was-instant-at-10-million-rows-and-timed-out-at-a-billion&quot;&gt;The query that was instant at 10 million rows and timed out at a billion&lt;/h3&gt;
&lt;p&gt;Here's the shape of it. It's a composite; I've seen it often enough that the specifics blur.&lt;/p&gt;
&lt;p&gt;A team builds a dashboard on their main OLTP database. One query carries it: count distinct users, grouped by day, over a date range. Against a 10-million-row staging copy it comes back instantly, everyone signs off, ship it. Months later production crosses a billion rows — roughly the &lt;a rel=&quot;nofollow&quot; href=&quot;https://tech.marksblogg.com/benchmarks.html&quot;&gt;1.1-billion-row scale in Mark Litwintschik's cross-engine benchmarks&lt;/a&gt; — and the identical query simply stops returning. On-call adds an index; nothing. A composite index; nothing. A bigger instance; somehow slightly worse. &quot;Throw it at Spark,&quot; one person says. &quot;Just use ClickHouse,&quot; says another. Nobody can explain why either would help. That's the real problem: everyone is arguing tactics with no framework for the decision.&lt;/p&gt;
&lt;p&gt;The query didn't regress. It's doing exactly what it always did. What changed is that the workload crossed two lines at once — it outgrew the engine class that suited it at 10M, and its sorts and hashes stopped fitting in memory. Name those two lines and the mystery turns into a decision you can defend.&lt;/p&gt;
&lt;h3 id=&quot;the-two-axis-framework&quot;&gt;The two-axis framework&lt;/h3&gt;
&lt;p&gt;When a query dies at scale, the reflex is to index harder. Sometimes that's right. At a billion rows it's usually the wrong axis. Two levers actually decide whether a query survives.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Axis 1 — Engine class.&lt;/strong&gt; Every database is built around one dominant access pattern, and that choice shapes everything downstream. A &lt;strong&gt;row-store OLTP&lt;/strong&gt; engine (PostgreSQL, MySQL) keeps whole rows together and excels at &quot;fetch or update this one record&quot; and narrow ranges. A &lt;strong&gt;columnar/OLAP&lt;/strong&gt; engine (ClickHouse, DuckDB, BigQuery) stores each column separately, so scanning and aggregating hundreds of millions of rows is the design point rather than the emergency. A &lt;strong&gt;distributed&lt;/strong&gt; engine (Citus, Spark, Cassandra) spreads data across nodes for horizontal scale, trading query flexibility to get it. These are peers, not rungs on a ladder; none is &quot;more advanced.&quot; Run a workload on the wrong one and you get a query that flies at 10M and dies at 1B.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Axis 2 — Memory budget.&lt;/strong&gt; Independent of engine class, every non-trivial query builds intermediate structures: sort buffers, hash tables for &lt;code&gt;GROUP BY&lt;/code&gt; and &lt;code&gt;DISTINCT&lt;/code&gt;, join tables. Fit those in RAM and the operation runs at memory speed. Overflow the memory the engine is allowed, and it spills to disk and falls off a cliff. PostgreSQL ships &lt;code&gt;shared_buffers&lt;/code&gt; at &lt;strong&gt;128MB&lt;/strong&gt; and &lt;code&gt;work_mem&lt;/code&gt; at just &lt;strong&gt;4MB&lt;/strong&gt;; the docs are explicit that a sort or hash exceeding &lt;code&gt;work_mem&lt;/code&gt; starts &quot;writing to temporary disk files&quot; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/runtime-config-resource.html&quot;&gt;PostgreSQL 18 — Resource Consumption&lt;/a&gt;, as of 2026). Four megabytes is nothing at a billion rows. This is the axis most write-ups skip, and it's why the same query is fast one day and glacial the next. The SQL didn't change; the data just got big enough to spill.&lt;/p&gt;
&lt;p&gt;The axes are orthogonal. You can pick the right engine class and still hit the memory cliff, or keep memory in check and still be on an engine that's wrong for the query's shape. So the move is: work out which quadrant you're actually in, then act on the axis that matches — change engine class, or change the memory budget — instead of adding another index to the axis that was never the problem.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/v01-two-axis-framework.png&quot; alt=&quot;Two-axis decision framework: engine class (row-store, columnar, distributed) by memory budget (fits RAM vs spills to disk), with each quadrant's failure mode and the right move.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;engine-class-1-row-store-oltp-great-at-point-lookups-brutal-on-full-table-aggregates&quot;&gt;Engine class 1: Row-store OLTP — great at point lookups, brutal on full-table aggregates&lt;/h3&gt;
&lt;p&gt;Row-stores are the workhorse for good reason. Ask for &quot;order #48213 and its line items&quot; or &quot;the last 50 events for this user&quot; and a row-store with the right index is hard to beat: it seeks to a handful of rows and returns. At 10M rows, the dashboard query was riding on that.&lt;/p&gt;
&lt;p&gt;Aggregates over the whole table are a different story, and the reason is baked into how MVCC row-stores stay correct under concurrency. Because different transactions can see different versions of a row, the engine keeps no cached count of how many rows a table has. An exact &lt;code&gt;count(*)&lt;/code&gt; therefore has to visit rows and check visibility — a sequential scan that grows linearly with the table.&lt;/p&gt;
&lt;p&gt;The numbers are blunt. In Joe Nelson's &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.citusdata.com/blog/2016/10/12/count-performance/&quot;&gt;Citus benchmark on counting&lt;/a&gt; (PostgreSQL 9.5.4, 2016 — treat the milliseconds as illustrative of the mechanism, which is unchanged today, not as current figures), an exact &lt;code&gt;count(*)&lt;/code&gt; ran &lt;strong&gt;85ms at 1M rows, 161ms at 2M, and 343ms at 4M&lt;/strong&gt;: a straight line, with the scan about &lt;strong&gt;88%&lt;/strong&gt; of the cost. Extend that to a billion rows and a single count is tens of seconds before any filtering. &lt;code&gt;count(1)&lt;/code&gt; doesn't rescue you; it measured slightly &lt;em&gt;slower&lt;/em&gt; (99ms vs 85ms at 1M), because Postgres special-cases the argument-free &lt;code&gt;count(*)&lt;/code&gt; while &lt;code&gt;count(1)&lt;/code&gt; re-checks the argument on every row. And this isn't Postgres being difficult — MySQL's InnoDB shares the MVCC property and also has no O(1) table count.&lt;/p&gt;
&lt;p&gt;When you don't need an exact number, stop asking for one. Every row-store keeps a maintained estimate for its planner, and you can read it directly. In PostgreSQL that's &lt;code&gt;pg_class.reltuples&lt;/code&gt; (the &lt;a rel=&quot;nofollow&quot; href=&quot;https://wiki.postgresql.org/wiki/Count_estimate&quot;&gt;Count-estimate pattern&lt;/a&gt;: &lt;code&gt;SELECT reltuples::bigint FROM pg_class WHERE oid = 'schema.table'::regclass&lt;/code&gt;). In the same benchmark the estimate returned in about &lt;strong&gt;0.3ms against 85ms&lt;/strong&gt; for the exact count at 1M rows — roughly &lt;strong&gt;280× faster&lt;/strong&gt;, and effectively constant time at any size. The catch is accuracy: it's an estimate and can't apply a &lt;code&gt;WHERE&lt;/code&gt; or &lt;code&gt;DISTINCT&lt;/code&gt;. For &quot;roughly how many rows&quot; on a dashboard, that's the difference between a billion-row scan and reading one catalog row.&lt;/p&gt;
&lt;p&gt;None of this means &quot;Postgres is slow.&quot; It means a full-table aggregate is off-design for a row-store. Approximate counts, materialized rollups, and summary tables all soften it. But if scans and aggregates are your &lt;em&gt;dominant&lt;/em&gt; pattern, you're fighting the engine class — and that's your first crossover signal.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/v02-aggregate-cliff.png&quot; alt=&quot;Exact count(*) rises linearly with table size while an approximate reltuples read stays flat and constant-time — roughly a 280x gap.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Exact `count(&lt;/em&gt;)&lt;code&gt; vs the &lt;/code&gt;reltuples` estimate. Source: Citus / Joe Nelson, PostgreSQL 9.5.4, 2016 — illustrative of the mechanism, not current figures.*&lt;/p&gt;
&lt;h3 id=&quot;engine-class-2-columnar-olap-where-scans-and-aggregates-are-the-design-point&quot;&gt;Engine class 2: Columnar / OLAP — where scans and aggregates are the design point&lt;/h3&gt;
&lt;p&gt;If aggregates are killing your row-store, what's built for them, and how much faster is it really? This is where the framework gets counter-intuitive.&lt;/p&gt;
&lt;p&gt;Columnar engines store each column contiguously instead of packing whole rows. A scan-and-aggregate query that touches 3 of 51 columns reads only those 3 off disk; the other 48 are never opened. And because a column holds one type with heavy repetition, it compresses hard, so there's less to read to begin with. Column pruning plus compression is why &quot;scan a billion rows and aggregate&quot; is routine here instead of an incident.&lt;/p&gt;
&lt;p&gt;The headline number: Litwintschik's &lt;a rel=&quot;nofollow&quot; href=&quot;https://tech.marksblogg.com/benchmarks.html&quot;&gt;1.1-billion-row taxi benchmark&lt;/a&gt; (1.1B rows, 51 columns, ~500GB uncompressed; page updated March 2024) runs one analytical query across dozens of engines. On Query 1, a single desktop ran &lt;strong&gt;ClickHouse in 0.088s&lt;/strong&gt; and &lt;strong&gt;DuckDB in 0.498s&lt;/strong&gt;. The same query took &lt;strong&gt;2.36s on a 21-node Spark cluster&lt;/strong&gt;, &lt;strong&gt;3.54s on 21-node Presto&lt;/strong&gt;, and &lt;strong&gt;1–3s on BigQuery&lt;/strong&gt;. Postgres with the &lt;code&gt;cstore_fdw&lt;/code&gt; columnar extension took &lt;strong&gt;152–368s&lt;/strong&gt; across the four queries, and plain SQLite's format took &lt;strong&gt;31,193s on Query 1 — about 8.7 hours&lt;/strong&gt;, included to show what the wrong engine class actually costs.&lt;/p&gt;
&lt;p&gt;A single desktop beat a 21-node cluster on a 1.1-billion-row scan by more than an order of magnitude. That one result is the whole framework in miniature. It isn't that ClickHouse or DuckDB is &quot;the best database&quot; — it's that for a scan-aggregate workload a columnar engine is in the right quadrant while the cluster pays coordination overhead it doesn't need at this size. (Caveats: one benchmark, one hardware generation, recent desktop CPUs for the fastest single-node runs. Trust the shape of the gap, not the exact milliseconds.)&lt;/p&gt;
&lt;p&gt;Compression is the other half, and it's an economics lever as much as a speed one. ClickBench — the ClickHouse team's &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/ClickBench&quot;&gt;scan/aggregate benchmark&lt;/a&gt; across 60+ systems — publishes a live &quot;Data Size&quot; column so you can compare on-disk footprint on the same ~100M-row dataset. Columnar layouts routinely pack this kind of wide, repetitive data down by a large multiple versus a row-store, though the exact ratio moves with engine, codec, and data, so read the live figure for the systems you're weighing rather than trusting a headline number. (ClickBench is ClickHouse-run, so treat it as a scan-workload reference, not a neutral verdict.)&lt;/p&gt;
&lt;p&gt;The honest counter-point: the same columnar engine that crushes the scan is the wrong tool for high-QPS single-row writes and point lookups. Insert a row and you touch every column store; update a field and compression works against you. The wide-column and key-value stores built for that job — Cassandra, ScyllaDB — sit at the opposite pole: excellent at point reads and writes on a known partition key, and structurally unfit for ad-hoc full-table aggregates. Cassandra's &lt;a rel=&quot;nofollow&quot; href=&quot;https://cassandra.apache.org/doc/latest/cassandra/developing/cql/dml.html&quot;&gt;CQL docs&lt;/a&gt; say it plainly: CQL &quot;only allows select queries that don't involve a full scan of all partitions,&quot; and forcing one needs &lt;code&gt;ALLOW FILTERING&lt;/code&gt;, whose performance the docs call &quot;unpredictable.&quot; A store modeled query-first around the partition key isn't a scan-aggregate engine, which is exactly why ClickBench lists Cassandra and ScyllaDB among the systems it couldn't benchmark on that suite. Right engine, wrong job, in both directions.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/v03-cross-engine-scan.png&quot; alt=&quot;Litwintschik 1.1-billion-row Query 1: a single desktop running ClickHouse or DuckDB beats a 21-node Spark/Presto cluster; SQLite is the wrong-tool extreme (log scale).&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Same query, same 1.1B-row dataset, across engine classes. Source: Mark Litwintschik, 1.1 Billion Taxi Rides benchmarks, page updated March 2024.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;engine-class-3-distributed-horizontal-scale-and-the-fan-out-ceiling&quot;&gt;Engine class 3: Distributed — horizontal scale, and the fan-out ceiling&lt;/h3&gt;
&lt;p&gt;When one node genuinely can't hold the data or serve the writes, distributed engines spread the work across many. The mental model most people carry — more nodes, more speed — holds right up until it doesn't, and knowing where that line sits is the difference between a defensible cluster and a single box with a network bill attached.&lt;/p&gt;
&lt;p&gt;The line: does the query push down to the shards, or force a reshuffle? Distribute by &lt;code&gt;tenant_id&lt;/code&gt; and group by &lt;code&gt;tenant_id&lt;/code&gt;, and each node computes its slice while the coordinator stitches the results together. That's pushdown, and it parallelizes beautifully. But the moment a query needs data spread across nodes — a &lt;code&gt;count(DISTINCT)&lt;/code&gt; on a non-distribution column, or a join across the distribution boundary — the engine has to reshuffle rows between workers first. That shuffle is the ceiling.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.citusdata.com/blog/2016/10/12/count-performance/&quot;&gt;Citus benchmark&lt;/a&gt; puts numbers on it (100M rows, 8 nodes, 32 shards; PG 9.5.4, 2016 — again mechanism-illustrative). A plain &lt;code&gt;count(*)&lt;/code&gt; across the cluster ran about &lt;strong&gt;1.2s&lt;/strong&gt;. A &lt;code&gt;count(DISTINCT)&lt;/code&gt; on the distribution column, which pushes down, ran about &lt;strong&gt;3.4s&lt;/strong&gt;. On a non-distribution column it can't push down, and pulling every row back to the coordinator is, in Nelson's words, &quot;really no better than counting on a single database instance … plus high network overhead.&quot; An HLL approximate distinct ran &lt;strong&gt;3.2–3.8s&lt;/strong&gt; — the row-store's approximation trick again, now buying a way around the shuffle. This generalizes well past Citus: in Spark the expensive stage is almost always the shuffle, and distributed query design is mostly the craft of keeping the hot queries pushed down.&lt;/p&gt;
&lt;p&gt;So distributed isn't a &quot;scale&quot; button. It's a trade — horizontal capacity for query flexibility and operational complexity. Align your access pattern with the distribution key and it's transformative. Keep needing cross-node data and you've bought nodes &lt;em&gt;and&lt;/em&gt; network latency to arrive back at roughly single-box performance. That mismatch is its own crossover signal, and the one most likely to be discovered the hard way in production.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/v04-fanout-pushdown.png&quot; alt=&quot;Two distributed query paths: one pushes down to shards and merges at the coordinator; the other forces a cross-node shuffle that collapses to a single box plus network overhead.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-memory-spill-axis-the-one-most-write-ups-skip&quot;&gt;The memory / spill axis — the one most write-ups skip&lt;/h3&gt;
&lt;p&gt;Now the second axis, and the reason &quot;just add an index&quot; so often does nothing. A query can be on exactly the right engine class and still fall off a cliff, because its sorts and hash tables outgrew the memory the engine was allowed and spilled to disk. This is engine-independent: columnar and distributed engines build the same buffers and spill the same way when they overflow their limits. Postgres is just the easiest place to watch it, because its docs name the knob.&lt;/p&gt;
&lt;h4 id=&quot;the-spill-cliff&quot;&gt;The spill cliff&lt;/h4&gt;
&lt;p&gt;At the default 4MB &lt;code&gt;work_mem&lt;/code&gt; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/runtime-config-resource.html&quot;&gt;PostgreSQL docs&lt;/a&gt;), a &lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;DISTINCT&lt;/code&gt;, or &lt;code&gt;ORDER BY&lt;/code&gt; that builds something larger switches from an in-memory hash or sort to an external merge that writes temp files to disk. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.citusdata.com/blog/2016/10/12/count-performance/&quot;&gt;Citus benchmark&lt;/a&gt; shows the size of the drop (PG 9.5.4, 2016; mechanism unchanged per the current docs): at default &lt;code&gt;work_mem&lt;/code&gt;, &lt;code&gt;count(DISTINCT)&lt;/code&gt; on an integer column averaged &lt;strong&gt;743ms&lt;/strong&gt;, but on a text column &lt;strong&gt;31,747ms&lt;/strong&gt; — a &lt;strong&gt;~43× gap&lt;/strong&gt; — with &lt;code&gt;Sort Method: external merge Disk&lt;/code&gt; in the plan. Give the operation enough &lt;code&gt;work_mem&lt;/code&gt; to stay in RAM and the integer case drops to about &lt;strong&gt;372ms&lt;/strong&gt; as an in-memory &lt;code&gt;HashAggregate&lt;/code&gt;. That in-RAM-versus-disk boundary is the axis. Stay on the right side and the query flies; cross it and it collapses.&lt;/p&gt;
&lt;p&gt;This is exactly why indexing didn't help the composite team. An index changes how you &lt;em&gt;find&lt;/em&gt; rows; it does nothing for a &lt;code&gt;GROUP BY&lt;/code&gt;/&lt;code&gt;DISTINCT&lt;/code&gt; whose result set is too big for &lt;code&gt;work_mem&lt;/code&gt;. The lever there is memory, or a smaller working set — not another index.&lt;/p&gt;
&lt;h4 id=&quot;when-to-re-architect-instead-of-tune&quot;&gt;When to re-architect instead of tune&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;work_mem&lt;/code&gt; is allocated per operation per query, and the docs warn total usage &quot;could be many times&quot; that value under load, so raising it globally just trades a spill problem for an out-of-memory one. Past a point the honest move is structural. PostgreSQL's &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/ddl-partitioning.html&quot;&gt;partitioning guidance&lt;/a&gt; offers a clean rule: partition &quot;when the size of the table [would] exceed the physical memory of the database server.&quot; Pruning then skips whole partitions — but it isn't free. The planner handles &quot;up to a few thousand&quot; partitions well only if pruning leaves few, since &quot;each partition requires its metadata to be loaded into the local memory of each session that touches it.&quot; Thousands of poorly-pruned partitions is its own problem.&lt;/p&gt;
&lt;h4 id=&quot;one-more-trap-deep-pagination&quot;&gt;One more trap: deep pagination&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;LIMIT ... OFFSET n&lt;/code&gt; gets slower the further you page. As Markus Winand explains in &lt;a rel=&quot;nofollow&quot; href=&quot;https://use-the-index-luke.com/no-offset&quot;&gt;&quot;No Offset&quot;&lt;/a&gt;, the database &quot;fetches and drops&quot; all &lt;code&gt;n&lt;/code&gt; preceding rows before returning the page, and results drift when rows are inserted between requests. A keyset (seek) query filters on the last-seen sort key instead and stays roughly constant-time however deep you go. Page latency that climbs with depth is a spill-adjacent signal, not a hardware one.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/v05-spill-cliff.png&quot; alt=&quot;The spill cliff: an in-RAM hash aggregate vs an external merge to disk, and the collapse back to memory speed when work_mem is raised.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;count(DISTINCT) at default vs raised work_mem. Source: Citus / Joe Nelson, PostgreSQL 9.5.4, 2016 — illustrative of the mechanism, not current figures.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-crossover-signal-checklist-change-engine-class-don-t-add-another-index&quot;&gt;The crossover-signal checklist: change engine class, don't add another index&lt;/h3&gt;
&lt;p&gt;This is the piece worth saving. A framework's job is to tell you &lt;em&gt;when&lt;/em&gt; you've hit the boundary and it's time to move on an axis instead of tuning further. Eight signals I watch for, each tied to the axis it moves. Two or more firing means you're past tuning and into an engine-class or memory-budget decision.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Scans and aggregates dominate the workload&lt;/strong&gt; (not point lookups). → Engine class, toward &lt;strong&gt;columnar&lt;/strong&gt;. The row-store aggregate cliff; indexing won't fix an off-design pattern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The working set exceeds server RAM.&lt;/strong&gt; → Memory. Postgres's own partition threshold; partition, re-architect, or move to columnar/distributed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sorts and &lt;code&gt;DISTINCT&lt;/code&gt;s keep spilling even after tuning &lt;code&gt;work_mem&lt;/code&gt;.&lt;/strong&gt; → Memory. You've hit the ceiling of per-query memory; the working set is the problem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pagination or scan latency rises with position.&lt;/strong&gt; → Memory/scan; switch to keyset/seek before switching engines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A single node has hit its write or IO ceiling.&lt;/strong&gt; → Engine class, toward &lt;strong&gt;distributed&lt;/strong&gt;. The one genuine &quot;add nodes&quot; signal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key &lt;code&gt;DISTINCT&lt;/code&gt;s or joins need data from several nodes&lt;/strong&gt; (a shuffle). → A warning on the distributed axis: without a distribution-key redesign, more nodes ≈ one box plus network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You need thousands of partitions to stay fast.&lt;/strong&gt; → Memory/metadata; the per-partition cost says a columnar or distributed store fits the data better.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You're keeping 500GB+ on one box for analytics.&lt;/strong&gt; → Engine class plus economics; columnar compression and distributed storage costs start driving the decision.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;What the list deliberately doesn't do is name a winning database. Each signal points to an axis and a direction, because the right specific engine depends on your query shapes, your team's ops maturity, and your budget. &quot;Change engine class&quot; is an architectural decision with a reason attached — something you can hand a skeptical peer or a budget owner instead of &quot;the internet said use ClickHouse.&quot;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/v06-crossover-checklist.png&quot; alt=&quot;The eight crossover signals, each tagged with the axis it moves (engine class or memory budget) and the recommended direction.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;cost-and-economics-for-the-decision-makers&quot;&gt;Cost and economics for the decision-makers&lt;/h3&gt;
&lt;p&gt;If you own the stack decision, latency is half the case; the half that gets budget approved is cost and defensibility. The benchmarks reframe cleanly as money.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A single big box can be cheaper &lt;em&gt;and&lt;/em&gt; faster than a cluster.&lt;/strong&gt; The &lt;a rel=&quot;nofollow&quot; href=&quot;https://tech.marksblogg.com/benchmarks.html&quot;&gt;Litwintschik result&lt;/a&gt; — one desktop-class node beating a 21-node cluster on a 1.1B-row scan — reads louder as a cost story than a speed one: 21 nodes carry ~21× the compute plus coordination, networking, and a standing ops burden, and still lose on this workload. For &quot;big but not planetary&quot; analytics, the first move is often the right engine class on one well-sized box, not a cluster. Distributed earns its cost when you truly exceed single-node capacity or need the availability.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Compression is a storage-cost lever.&lt;/strong&gt; A columnar layout shrinks wide, repetitive data by a large multiple (measure it for your engines from &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/ClickBench&quot;&gt;ClickBench's &quot;Data Size&quot; column&lt;/a&gt; — it varies by engine and codec), which is a recurring cut in bytes stored and, in a warehouse, fewer dollars per query scanned.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-query billing cuts both ways.&lt;/strong&gt; A serverless warehouse (BigQuery ran 1–3s per query here) fits spiky, ad-hoc analytics where an idle cluster would bleed money, and fits badly for a high-frequency dashboard hammering one aggregate, where a right-sized columnar box wins on total cost.&lt;/p&gt;
&lt;p&gt;Every price and benchmark here will rotate — pricing shifts, hardware moves the single-node ceiling, vendor benchmarks get re-run. Don't anchor the decision to one dated figure. The framework doesn't rotate: two axes, eight signals. If a price moves, re-run the comparison.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/v07-cost-ops-profile.png&quot; alt=&quot;Cost and operations profile per engine class: relative storage cost, node count, operational burden, query flexibility, and best-fit workload.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;build-it-yourself&quot;&gt;Build it yourself&lt;/h3&gt;
&lt;p&gt;The framework is cheap until you feel it on your own data. Three projects, beginner to advanced. Each names a real tool (links verified reachable in 2026, but repos move — check before you lean on them).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Project 1 — Count your table three ways (~30 min).&lt;/strong&gt; Feel the row-store aggregate cliff on real data. You'll need a Postgres or MySQL table with 10M+ rows and &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/duckdb/duckdb&quot;&gt;DuckDB&lt;/a&gt;. Run &lt;code&gt;EXPLAIN ANALYZE SELECT count(*) FROM your_table;&lt;/code&gt; and note the time and the &lt;code&gt;Seq Scan&lt;/code&gt;; then &lt;code&gt;SELECT reltuples::bigint FROM pg_class WHERE oid = 'schema.your_table'::regclass;&lt;/code&gt;; then get a slice into columnar form — export CSV (&lt;code&gt;COPY your_table TO 'slice.csv' (FORMAT csv)&lt;/code&gt;) and load it into DuckDB, or read the table directly with &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/duckdb/pg_duckdb&quot;&gt;pg_duckdb&lt;/a&gt; — and &lt;code&gt;count(*)&lt;/code&gt; there. &lt;strong&gt;Done when&lt;/strong&gt; you can state your own numbers for all three and point to the &lt;code&gt;Seq Scan&lt;/code&gt;. Stretch: add &lt;code&gt;GROUP BY day&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Project 2 — Reproduce the spill cliff (~1–2 hrs).&lt;/strong&gt; Watch a query cross the RAM→disk boundary. You'll need Postgres and a 1M+ row table with an integer and a text column (the NYC-taxi or ClickBench sets work). At default &lt;code&gt;work_mem&lt;/code&gt;, run &lt;code&gt;EXPLAIN ANALYZE SELECT count(DISTINCT int_col) FROM t;&lt;/code&gt; then the same on &lt;code&gt;text_col&lt;/code&gt;, and confirm &lt;code&gt;Sort Method: external merge Disk&lt;/code&gt; on the slow one. &lt;code&gt;SET work_mem = '1GB';&lt;/code&gt;, re-run both. &lt;strong&gt;Done when&lt;/strong&gt; you've captured your own before/after ratio and seen the plan flip to in-memory &lt;code&gt;HashAggregate&lt;/code&gt;. Stretch: chart latency as you step &lt;code&gt;work_mem&lt;/code&gt; from 4MB to 1GB and find your own cliff edge.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Project 3 — Run a slice of the 1.1B benchmark (~half a day).&lt;/strong&gt; Reproduce the crossover on your hardware. You'll need the same big table in a row-store and a columnar engine, plus the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/ClickBench&quot;&gt;ClickBench loaders&lt;/a&gt; (60+ DBMS setup scripts) and &lt;a rel=&quot;nofollow&quot; href=&quot;https://tech.marksblogg.com/benchmarks.html&quot;&gt;Litwintschik's posts&lt;/a&gt; for reference. Load an identical scan-aggregate table into each, time one representative query on both, and record the ratio and your hardware. &lt;strong&gt;Done when&lt;/strong&gt; you reproduce a &amp;gt;100× gap — or can explain from the plans why yours differs. Stretch: add a distributed engine and build a query that forces a shuffle to find your own fan-out ceiling.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://unrivaled-starlight-d655be.netlify.app/blog/visuals/v08-project-ladder.png&quot; alt=&quot;Build-it-yourself project ladder: beginner to advanced, each rung mapped to an axis — aggregate cliff, spill cliff, cross-engine crossover.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-to-do-monday-morning&quot;&gt;What to do Monday morning&lt;/h3&gt;
&lt;p&gt;The whole thing, compressed. When a query dies at scale, stop asking &quot;which index?&quot; and ask the two questions that decide it. Engine class: is the dominant shape point-lookups (row-store), scans and aggregates (columnar), or genuinely bigger than one node (distributed)? Memory budget: does the working set fit in RAM, or is it spilling? Then walk the eight signals; two or more means the move is on an axis, not another index. There's no single answer to &quot;how do I handle a billion rows&quot; — the answer is a decision you can write down and defend.&lt;/p&gt;
&lt;p&gt;So go run it. Take your biggest table this week and do Project 1: &lt;code&gt;count(*)&lt;/code&gt;, then &lt;code&gt;reltuples&lt;/code&gt;, then a columnar count in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/duckdb/duckdb&quot;&gt;DuckDB&lt;/a&gt;. Watch where the plan shows a &lt;code&gt;Seq Scan&lt;/code&gt; and how far apart the three numbers land. Want the full crossover? The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/ClickBench&quot;&gt;ClickBench repo&lt;/a&gt; has loaders for 60+ engines, so you can time the same scan on a row-store and a columnar engine side by side. Bring back your own numbers — the framework only lands once the cliff is yours.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30403/instant-at-10-million-rows-dead-at-a-billion-a-decision-framework-for-billion-row-sql</guid>
<pubDate>Fri, 09 Oct 2026 06:03:06 +0000</pubDate>
</item>
<item>
<title># Power BI Data Modelling: A Great Path to Great Analysis</title>
<link>https://coderlegion.com/30210/power-bi-data-modelling-a-great-path-to-great-analysis</link>
<description>&lt;h2 id=&quot;data-modeling-in-power-bi&quot;&gt;Data Modeling in Power BI&lt;/h2&gt;
&lt;h3 id=&quot;introduction-2&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;At a time when data is rapidly generated by different activities ranging from experiments to business, more often do we get data in different formats and structures. The data may or may not be related depending on the source. Related data in different formats and structure need to be re-organized to establish existing relationships for accurate analysis and reporting. To establish data model relationships and properties we need advanced tools that can connect to these sources. Then load the different datasets from source, and perform modelling to connect the links.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Data Modeling&lt;/em&gt; therefore, becomes very important. &lt;em&gt;Data Modeling is the process of defining the data structure, properties, and relationships within a &lt;em&gt;Data Model&lt;/em&gt;.&lt;/em&gt; Through organizing tables and columns, defining relationships between tables in the model, setting cardinality and cross-filter direction we achieve the best model which supports accurate calculations and reporting.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;A Data Model&lt;/em&gt; is the logical representation of how data is structured and related within the data model.&lt;/p&gt;
&lt;p&gt;Before modeling ensure that your data is clean and up to date, ready for modeling.&lt;/p&gt;
&lt;h5 id=&quot;why-data-modeling&quot;&gt;Why Data Modeling&lt;/h5&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Improved Performance and Scalability&lt;/em&gt; - join optimization, filters, and aggregations lead to a good model that is efficient and reliable. However, poorly designed models with complex relationships and redundant records cause delays, making queries slow.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Accuracy and Consistency&lt;/em&gt; - Guarantees the accuracy and consistency of the data used, making it reliable in reporting and drawing precise  insights and improved decision making.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Supports Data Exploration&lt;/em&gt; - Users can use hierarchies and drill-down paths to support efficient data exploration.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Ease of Maintenance&lt;/em&gt; - Any changes in one model or report are reflected everywhere in real time enabling reusability and scalability, reducing the effort required to maintain reports and models as the business grows.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;components-of-power-bi-data-model&quot;&gt;Components of Power BI Data Model&lt;/h3&gt;
&lt;p&gt;a. &lt;em&gt;Tables and Sources&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Tables&lt;/em&gt; are the raw building blocks of data models. They contain fields and records of particular data from different sources.&lt;br&gt;
&lt;em&gt;Sources,&lt;/em&gt; where we get our data from can vary depending on the context. We can get our data from SQL Database servers, Excel files, Data warehouse, SharePoint, and Power BI Dataflows.&lt;/p&gt;
&lt;h5 id=&quot;designing-source-tables&quot;&gt;Designing Source Tables&lt;/h5&gt;
&lt;p&gt;i. &lt;em&gt;Fact Tables&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Fact tables contain quantitative data, numerical data that can be aggregated and analyzed. Examples the &lt;em&gt;Sales, Orders, or Invoices.&lt;/em&gt; This information is about specific events, business transaction, or measurements at the core of the data model.&lt;/p&gt;
&lt;p&gt;Fact tables keeps one record for each transaction or event, includes only keys and metrics, and they avoid storing descriptive attributes.&lt;/p&gt;
&lt;p&gt;Fact tables contain:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Foreign Key(FK)&lt;/em&gt; - a link to the Dimension table i.e. DateKey, ProductKey, e.t.c.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Measures(Numeric values)&lt;/em&gt; - Quantitative data you can aggregate. i.e. Quantity, UnitPrice, TotalAmount etc.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Degenerate dimensions&lt;/em&gt; - Descriptive data that does not warrant its dimension table. i.e. OrderNumber, InvoiceNumber.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ii. &lt;em&gt;Dimension Tables&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Dimension tables provide context or additional details to the data in &lt;em&gt;Fact Tables&lt;/em&gt;. They contains fewer rows while fact tables can contain a large number of rows and they are on growing.&lt;/p&gt;
&lt;p&gt;Relationship are used to establish links and connections between the fact and dimension tables in Power BI.&lt;/p&gt;
&lt;p&gt;Dimension table contain:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Primary Key(PK)&lt;/em&gt; - Unique identifier e.g.  &lt;code&gt;ProductID&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Descriptive attributes&lt;/em&gt; - Text, categories, hierarchies e.g. &lt;code&gt;ProductName&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Hierarchy Columns&lt;/em&gt; - Columns that form drill-down paths e.g. Country -&amp;gt; City -&amp;gt; Town.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Natural key&lt;/em&gt; - original identifier from the source system e.g. &lt;code&gt;ProductCode&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;iii. &lt;em&gt;Reference and Lookup Tables&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;They are small but critical tables in supporting the integrity and interpretation of data. They support additional mappings  or categorizations. Example: DiscountTypes or StatusCodes.&lt;/p&gt;
&lt;h5 id=&quot;loading-data&quot;&gt;Loading Data&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Open Power BI&lt;/li&gt;
&lt;li&gt;On the Ribbon &lt;code&gt;Home&lt;/code&gt; tab, Click on &lt;code&gt;Get Data&lt;/code&gt; from the &lt;em&gt;Data&lt;/em&gt; group option.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/u4k0yh2aocmcpqkzobxw.png&quot; alt=&quot;Get Data Icon&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;From the drop down list, choose the source of your data. i.e. &lt;code&gt;Text/CSV&lt;/code&gt; if your data is text or comma separated file.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/e7iqcv5qwsoa15ffkif8.png&quot; alt=&quot;Data Source&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Choose the path to your file i.e. &lt;code&gt;C:/Users/User/Downloads/file.csv&lt;/code&gt; and click on &lt;code&gt;Open&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Click on &lt;code&gt;Connect&lt;/code&gt; on the Power Query editor window.&lt;/li&gt;
&lt;li&gt;Your data is extracted to Power Query for cleaning. Click on &lt;code&gt;Transform - Data&lt;/code&gt; to clean explore and clean it on the Power Query editor.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/spze6r2i7ekktnp3xsy5.png&quot; alt=&quot;Query Editor Window&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/3wsmqrfwt2dov26sik8o.png&quot; alt=&quot;Cleaning Window&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Finally, on the Power Query Editor Window Ribbon, Click on &lt;code&gt;Close &amp;amp; Apply&lt;/code&gt; from the &lt;code&gt;Home&lt;/code&gt; tab.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/vgs0p47x4kyktm1t4i4p.png&quot; alt=&quot;Close and Apply Button&quot;&gt;&lt;/p&gt;
&lt;p&gt;Changes are loaded and applied to Power BI. We will load all our &lt;a rel=&quot;nofollow&quot; href=&quot;https://mavenanalytics.io/data-playground/toy-store-e-commerce-database&quot;&gt;Toy Store E-Commerce Database&lt;/a&gt; data set for modeling using the procedure above. The data is already clean, we are going to use it for demonstrating relationships in data modeling.&lt;/p&gt;
&lt;p&gt;b. &lt;em&gt;Relationships&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Relationships are an important aspect of data modeling in Power BI. Relationships are for establishing connections and links between tables of a data model. They allow related tables to communicate with each other. They are the link between &lt;em&gt;Fact Tables&lt;/em&gt; and &lt;em&gt;Dimension Tables&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Relationships determine how data in the model is filtered, aggregated, combined, and visualized. They're critical to the accuracy and performance of queries in Power BI.&lt;/p&gt;
&lt;h6 id=&quot;cardinality&quot;&gt;Cardinality&lt;/h6&gt;
&lt;p&gt;Cardinality type defines each relationship in a model. &lt;em&gt;Cardinality&lt;/em&gt; refers to the number of unique values in one table related to the number of unique values in another.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Types of Cardinality in Relationships&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;One-to-One(1:1)&lt;/em&gt; - Every single record in one table is related to another single record in another table. Commonly used when splitting a large table for performance and security reasons.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;One-to-Many(1:N)&lt;/em&gt; - Refers to when a single record in one table is related to many records in another table(s). Very common type of relationship in Power BI models. The &lt;em&gt;Fact&lt;/em&gt; table is on the &lt;em&gt;Many&lt;/em&gt; side while the &lt;em&gt;Dimension&lt;/em&gt; table is on the &lt;em&gt;One&lt;/em&gt; side when linking &lt;em&gt;Fact&lt;/em&gt; tables and &lt;em&gt;Dimension&lt;/em&gt; tables.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Many-to-Many(N:N)&lt;/em&gt; - Refers to when many records in the first table are related to many other records in the second table.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h6 id=&quot;cross-filter-directions&quot;&gt;Cross Filter Directions&lt;/h6&gt;
&lt;p&gt;Used to determine the direction which filters propagate.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Single&lt;/em&gt; - When applied, only one of the tables in the relationship can filter data across the other table. Filtering data on the other table does not affect the data shown on the first table.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Bi-Directional&lt;/em&gt; - When applied, data can be filtered on either direction between the tables. Filtering data in one table affects data shown on the other table and vice versa. It is less often used and can impact performance negatively and create ambiguous filters.&lt;/p&gt;
&lt;p&gt;Cross-filter options are largely dependent on the cardinality type.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Cardinality &lt;/th&gt;&lt;th&gt;Cross-Filter Options&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1:1 &lt;/td&gt;&lt;td&gt;Bi-Directional&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1:* &lt;/td&gt;&lt;td&gt;Single or Bi-Directional&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;em&gt;:&lt;/em&gt; &lt;/td&gt;&lt;td&gt;Single or Bi-Directional&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;Table 1: A table showing cardinality and their cross-filter options.&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;creating-a-relationship&quot;&gt;Creating a Relationship&lt;/h4&gt;
&lt;p&gt;To create relationships, first you need to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Identify the matching columns(keys).&lt;/li&gt;
&lt;li&gt;Choose the right cardinality.&lt;/li&gt;
&lt;li&gt;Decide on the Filter direction.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;the-model-view-interface&quot;&gt;The Model View Interface&lt;/h5&gt;
&lt;p&gt;To manage relationships in Power BI Desktop:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Click the Model icon in the left sidebar (the icon with three connected boxes)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/4fw45c9o13jvbf7fimho.png&quot; alt=&quot;Model&quot;&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;You'll see all your tables displayed as boxes with their columns listed&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/3jhqw6m20jhiip0t3mn9.png&quot; alt=&quot;Tables in the Model&quot;&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Lines between tables represent relationships&lt;/li&gt;
&lt;li&gt;The line style indicates cardinality: 1 on one end and * on the many end&lt;/li&gt;
&lt;/ol&gt;
&lt;h5 id=&quot;auto-detect-relationships&quot;&gt;Auto-Detect Relationships&lt;/h5&gt;
&lt;p&gt;When you load tables, Power BI automatically recognizes columns with same data type and name and creates relationships. However, you shoul always create relationships manually or review those created automatically. &lt;/p&gt;
&lt;p&gt;&lt;em&gt;Just like in our case, Power BI automatically creates relationships between tables in our model. We will review them.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;When auto-detect works well:&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Column names match exactly (e.g., ProductID in both tables)&lt;/li&gt;
&lt;li&gt;Data types are compatible&lt;/li&gt;
&lt;li&gt;Values actually correspond between the tables&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;creating-relationships-manually&quot;&gt;Creating Relationships Manually&lt;/h5&gt;
&lt;p&gt; Using the Managing Relationship Dialog&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;On the &lt;code&gt;Home&lt;/code&gt; tab  → &lt;code&gt;Manage Relationships&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/mqre7817gnrqv31vn7ng.png&quot; alt=&quot;Manage Relationships&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Click &lt;code&gt;New&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/k5owghexz7iiyclqvdli.png&quot; alt=&quot;New Relationship&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Select the first table and column, &lt;code&gt;From Table&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Select the second table and column, &lt;code&gt;To Table&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Configure cardinality and cross-filter direction&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/images/specifytables.png&quot; alt=&quot;Configure Relationship Tables&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Click &lt;code&gt;Save&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h6 id=&quot;step-by-step-for-creating-relationships-for-our-toy-store-e-commerce-database&quot;&gt;Step by Step for Creating Relationships for our Toy Store E-Commerce Database&lt;/h6&gt;
&lt;ol&gt;
&lt;li&gt;Load all tables: &lt;pre&gt;&lt;code&gt; ```
     products
     orders
     order_items
     order_item_refunds
     website_sessions
     website_pageviews
 ````
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Open Model view and delete any autodetected relationship.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;em&gt;To delete a relationship:&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In Model view, right-click on the line joining the &lt;code&gt;products&lt;/code&gt; and &lt;code&gt;order_items&lt;/code&gt; tables&lt;/li&gt;
&lt;li&gt;From the options displayed, Click on &lt;code&gt;Delete&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/i7ete2c79b1mp6sm1od3.png&quot; alt=&quot;Delete Relationship&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Save your work&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;Create the following relationships:&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;From Fact Table &lt;/th&gt;&lt;th&gt;To Dimension Table &lt;/th&gt;&lt;th&gt;Cardinality &lt;/th&gt;&lt;th&gt;Cross-Filter&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;products[product_id] &lt;/td&gt;&lt;td&gt;order_items[product_id] &lt;/td&gt;&lt;td&gt;One-to-Many &lt;/td&gt;&lt;td&gt;Single&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;orders[order_id] &lt;/td&gt;&lt;td&gt;order_items[order_id] &lt;/td&gt;&lt;td&gt;One-to-Many &lt;/td&gt;&lt;td&gt;Single&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;orders[order_id] &lt;/td&gt;&lt;td&gt;order_item_refunds[order_id] &lt;/td&gt;&lt;td&gt;One-to-Many &lt;/td&gt;&lt;td&gt;Single&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;website_sessions[website_session_id] &lt;/td&gt;&lt;td&gt;orders[website_session_id] &lt;/td&gt;&lt;td&gt;One-to-One &lt;/td&gt;&lt;td&gt;Bi-Direction&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;website_sessions[website_session_id] &lt;/td&gt;&lt;td&gt;website_pageviews[website_session_id] &lt;/td&gt;&lt;td&gt;One-to-Many &lt;/td&gt;&lt;td&gt;Single&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;order_items[order_item_id] &lt;/td&gt;&lt;td&gt;order_item_refunds[order_item_id] &lt;/td&gt;&lt;td&gt;One-to-one &lt;/td&gt;&lt;td&gt;Bi-Direction&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;Table 2: Table showing relationships to be created.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Creating a relationship between the &lt;code&gt;products&lt;/code&gt; and &lt;code&gt;order_items&lt;/code&gt; tables&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;On the &lt;code&gt;Home&lt;/code&gt; tab  → &lt;code&gt;Manage Relationships&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;code&gt;New&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Select the &lt;code&gt;products&lt;/code&gt; table and specify the column &lt;code&gt;product_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Select the &lt;code&gt;order_items&lt;/code&gt; table and specify the column &lt;code&gt;product_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Set cardinality to &lt;code&gt;One-to-Many&lt;/code&gt; and the cross-filter as &lt;code&gt;Single&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/tikqfcb3k8mogoo03723.png&quot; alt=&quot;Specify Table Configurations&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Click &lt;code&gt;Save&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/qdh3npai9fx6pj2b4jk5.png&quot; alt=&quot;Established Relationship&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Complete creating all the other relationships following the procedure. You will get something similar to:&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/fbqtjzxxq3zha4emmxfp.png&quot; alt=&quot;All Relationships&quot;&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Verify by checking that filters from any dimension table correctly filter the fact table.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;conclusion-2&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Data modeling is very crucial to building powerful reports and visualizations. It is key to great analysis because it establishes relationships, links, and connections existing in a data model making it wholesome and complete. Further, it improves query performance increasing efficiency and reliability of queries. Correct data modeling can lead to accurate reporting and result reliability, thus informed decision making. To view the complete model and data for your practice on GitHub visit &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MakoriNyachaki/Toy-Store-E-Commerce&quot;&gt;Toy Store E-Commerce&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30210/power-bi-data-modelling-a-great-path-to-great-analysis</guid>
<pubDate>Thu, 08 Oct 2026 03:59:58 +0000</pubDate>
</item>
<item>
<title>¿Tu backup sobrevivirá a la próxima versión? La prueba que me faltaba</title>
<link>https://coderlegion.com/30139/tu-backup-sobrevivira-a-la-proxima-version-la-prueba-que-me-faltaba</link>
<description>&amp;lt;!--
  CoderLegion · Article · Post 02.1
  Serie: Software auditable (S1)
  Autor: Ignacio Badenes (@yosoyignicion / IgnicionDev)
  Idioma: bilingüe ES + EN · Read time: ~9 min
  Tags: python, database, testing, opensource
  Agradecimiento: comentario de @Mike Dabydeen (Líder de Comunidad)
--&amp;gt;
&lt;h2 id=&quot;-tu-backup-sobrevivir-a-la-pr-xima-versi-n-la-prueba-que-me-faltaba&quot;&gt;¿Tu backup sobrevivirá a la próxima versión? La prueba que me faltaba&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Serie:&lt;/strong&gt; &lt;em&gt;Software auditable&lt;/em&gt; — Post 02.1&lt;br&gt;
&lt;strong&gt;Read time:&lt;/strong&gt; ~9 min · &lt;strong&gt;Etiquetas:&lt;/strong&gt; &lt;code&gt;python&lt;/code&gt; &lt;code&gt;database&lt;/code&gt; &lt;code&gt;testing&lt;/code&gt; &lt;code&gt;opensource&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cre-haber-cerrado-el-problema&quot;&gt;Creí haber cerrado el problema&lt;/h3&gt;
&lt;p&gt;Hay una tranquilidad que no es ingeniería: es la sensación de haber terminado algo porque ya no duele. El &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/30129/tu-backup-no-sirve-si-no-puedes-restaurarlo-el-wal-que-vacio-mi-base&quot;&gt;Post 02&lt;/a&gt; terminaba con una frase que me gustaba —&lt;em&gt;recuperar es el trabajo&lt;/em&gt;— y con un test que restauraba una copia en una máquina limpia. Lo di por resuelto.&lt;/p&gt;
&lt;p&gt;No lo estaba. Había confundido &lt;strong&gt;mi&lt;/strong&gt; tranquilidad con una garantía.&lt;/p&gt;
&lt;p&gt;Esto no va de arreglar un &lt;code&gt;if&lt;/code&gt;. Va de un error de perspectiva: miré el sistema en el eje del &lt;em&gt;espacio&lt;/em&gt; (¿funciona en otra máquina?) y me olvidé del eje del &lt;em&gt;tiempo&lt;/em&gt; (¿funcionará cuando el software haya cambiado?). Un backup es, por definición, una promesa que se hace al futuro. Y yo la estaba validando solo en el presente.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;el-comentario-que-volvi-a-mirar-donde-yo-no-miraba&quot;&gt;El comentario que volvió a mirar donde yo no miraba&lt;/h3&gt;
&lt;p&gt;Mike Dabydeen, líder de la comunidad, leyó el post y escribió lo que yo no había querido ver. Su frase central: mi prueba demostraba que &lt;strong&gt;la copia de hoy se restaura con el código de hoy&lt;/strong&gt;. Justo el caso que nadie necesita. Quien de verdad depende de un backup quiere restaurar una copia de hace un año con la versión que exista dentro de un año.&lt;/p&gt;
&lt;p&gt;Cito sus dos ideas, porque las dos cambiaron el diseño:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;La prueba en máquina limpia demuestra que la copia de hoy se restaura con el código de hoy. Lo que todavía no cubre es que una copia hecha con la 1.3.0 se restaure con la versión que publiques dentro de un año, y ese es justo el caso de quien de verdad necesita su respaldo. Yo guardaría en el repo un paquete real generado por la 1.3.0 como accesorio y lo restauraría en CI en cada versión.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Sobre el error del &lt;code&gt;-wal&lt;/code&gt;, hay otra opción: restaurar con la misma API de copia de seguridad de SQLite, pero en sentido inverso... la copia mantiene abierta una transacción de escritura en el destino durante todo el proceso y la revierte si no termina, y el &lt;code&gt;-wal&lt;/code&gt; que encuentra es el de esa misma base, así que no hay archivos huérfanos que borrar.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Tenía razón en las dos. Y la primera duele más, porque no es un bug: es una &lt;strong&gt;promesa sin testigo&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;acto-i-el-tiempo-como-testigo&quot;&gt;Acto I — El tiempo como testigo&lt;/h3&gt;
&lt;h4 id=&quot;la-trampa-del-test-que-pasa&quot;&gt;La trampa del test que pasa&lt;/h4&gt;
&lt;p&gt;Mi test de recuperación era honesto y, aun así, insuficiente. Hacía esto:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# El test que probaba el presente
bundle = create_backup(source)        # se genera con el código actual
restore_backup(bundle, clean)         # y se restaura con el código actual
assert count_scans(clean) == 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generación y restauración usaban &lt;strong&gt;el mismo código&lt;/strong&gt;. Un test cerrado sobre sí mismo: si mañana cambio el esquema y rompo la ruta de restauración, este test seguiría verde mientras las copias de los usuarios dejarían de restaurarse. La prueba de mi promesa era la promesa mirándose en un espejo.&lt;/p&gt;
&lt;p&gt;Una promesa solo es real si sobrevive a quien la hizo.&lt;/p&gt;
&lt;h4 id=&quot;la-soluci-n-una-escalera-de-fixtures&quot;&gt;La solución: una escalera de fixtures&lt;/h4&gt;
&lt;p&gt;Congelé un &lt;strong&gt;paquete real&lt;/strong&gt; generado por la 1.3.0 y lo commité como &lt;em&gt;golden file&lt;/em&gt;. En cada versión, CI debe restaurarlo en una máquina limpia:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tests/fixtures/backups/
├── aethernet-1.3.0-schema5.tar.gz          # el paquete real, ~5 KB
├── aethernet-1.3.0-schema5.expected.json   # su procedencia y las aserciones
└── README.md                               # cómo ampliar la escalera
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El &lt;code&gt;.expected.json&lt;/code&gt; guarda lo que hace verificable la prueba: &lt;code&gt;sqlite_version&lt;/code&gt;, &lt;code&gt;page_size&lt;/code&gt;, &lt;code&gt;db_sha256&lt;/code&gt; y los conteos que deben sobrevivir (escaneos, dispositivos, eventos). El test recorre &lt;strong&gt;todos&lt;/strong&gt; los paquetes de la carpeta, no uno:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.parametrize(&quot;bundle&quot;, sorted(FIXTURES.glob(&quot;*.tar.gz&quot;)), ids=lambda p: p.name)
def test_historical_backup_restores_on_clean_machine(bundle, tmp_path):
    expected = json.loads(_expected_path(bundle).read_text())
    result = restore_backup(bundle, _clean_paths(tmp_path))     # máquina limpia
    repo = Repository(Database(result.db_path))
    assert repo.counts()[&quot;wifi_scans&quot;] == expected[&quot;counts&quot;][&quot;wifi_scans&quot;]
    # ...informe regenerado, config restaurada, esquema migrado a v5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La regla de mantenimiento la dejó escrita el propio Mike: &lt;strong&gt;cuando cambie el esquema o el formato, añade un paquete nuevo y conserva los anteriores&lt;/strong&gt;. Lo automaticé en un generador, &lt;code&gt;scripts/gen_backup_fixture.py&lt;/code&gt;, que usa siempre el código actual y &lt;strong&gt;congela reloj y hostname&lt;/strong&gt; para que el artefacto no filtre la máquina de quien lo creó.&lt;/p&gt;
&lt;p&gt;Aquí está lo importante: &lt;code&gt;MIGRATIONS&lt;/code&gt; es &lt;em&gt;append-only&lt;/em&gt;; una copia del esquema v5 se migra sola hacia adelante. Así, la escalera deja de ser una promesa en un &lt;code&gt;CHANGELOG&lt;/code&gt; y pasa a ser &lt;strong&gt;un test que falla el día que alguien rompa una migración&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;acto-ii-la-elegancia-de-hacer-menos&quot;&gt;Acto II — La elegancia de hacer menos&lt;/h3&gt;
&lt;h4 id=&quot;del-borrado-defensivo-a-la-primitiva-correcta&quot;&gt;Del borrado defensivo a la primitiva correcta&lt;/h4&gt;
&lt;p&gt;En el Post 02 arreglé el &lt;code&gt;-wal&lt;/code&gt; que vaciaba la base restaurada a base de &lt;strong&gt;borrar&lt;/strong&gt; los ficheros auxiliares huérfanos:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;_remove_sidecars(paths.db_path)     # fuera el -wal/-shm
os.replace(staged_db, paths.db_path)
_remove_sidecars(paths.db_path)     # y otra vez, por si acaso
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Funciona, pero es una solución por fuerza bruta: limpio el desorden que otra cosa dejó. Mike propuso bajar un nivel y usar &lt;strong&gt;la misma primitiva de SQLite, en sentido inverso&lt;/strong&gt;. En vez de renombrar un fichero y rezar por los sidecars, dejo que SQLite &lt;em&gt;escriba&lt;/em&gt; la base preparada &lt;strong&gt;dentro&lt;/strong&gt; de la base destino:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _replace_db(staged_db: Path, target: Path) -&amp;gt; None:
    # Backup inverso: origen -&amp;gt; destino. SQLite abre una transacción de
    # escritura sobre el destino y la revierte si no termina.
    if _page_size(target) in (0, _page_size(staged_db)):
        try:
            _consistent_copy(staged_db, target)
            return
        except sqlite3.OperationalError as exc:
            if &quot;locked&quot; in str(exc).lower() or &quot;busy&quot; in str(exc).lower():
                raise BackupError(&quot;la base destino está en uso; detén el daemon antes de restaurar&quot;)
    # Fallback: reemplazo atómico con limpieza de sidecars
    _remove_sidecars(target)
    os.replace(staged_db, target)
    _remove_sidecars(target)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Las ventajas son las que él describió: no hay ventana en la que la base no exista, no quedan &lt;code&gt;-wal&lt;/code&gt;/&lt;code&gt;-shm&lt;/code&gt; huérfanos (el WAL que aparece es el de la propia base) y, si algo falla a mitad, el destino &lt;strong&gt;se revierte&lt;/strong&gt;. Verifiqué el comportamiento con un experimento antes de escribir una línea: la copia inversa sobre una base WAL con conexión viva deja los datos correctos y no deja sidecar huérfano.&lt;/p&gt;
&lt;p&gt;Hay elegancia en hacer menos y confiar en el motor que ya conoce sus invariantes.&lt;/p&gt;
&lt;h4 id=&quot;la-condici-n-que-no-se-puede-ignorar&quot;&gt;La condición que no se puede ignorar&lt;/h4&gt;
&lt;p&gt;El backup inverso tiene un requisito real: si el destino está en modo WAL, &lt;strong&gt;ambos ficheros deben tener el mismo &lt;code&gt;page_size&lt;/code&gt;&lt;/strong&gt;. Si no coinciden, SQLite aborta con un &lt;code&gt;OperationalError&lt;/code&gt;. Eso lo convierte en una restricción de diseño, no en una nota al pie:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Si los &lt;code&gt;page_size&lt;/code&gt; &lt;strong&gt;coinciden&lt;/strong&gt; → backup inverso (limpio, con rollback).&lt;/li&gt;
&lt;li&gt;Si &lt;strong&gt;no coinciden&lt;/strong&gt; → &lt;em&gt;fallback&lt;/em&gt; atómico conservando la limpieza de sidecars.&lt;/li&gt;
&lt;li&gt;Si el destino está &lt;strong&gt;bloqueado&lt;/strong&gt; por un escritor vivo → se &lt;strong&gt;aborta con aviso&lt;/strong&gt;, en lugar de competir por el fichero y corromperlo en silencio.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Ese último caso es el más importante para el usuario: &lt;code&gt;restore --force&lt;/code&gt; sobre un daemon activo ya no es una ruleta. Si no puedo garantizar la transición, &lt;strong&gt;prefiero decir que no&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;lo-que-esto-cambia-takeaway-&quot;&gt;Lo que esto cambia (Takeaway)&lt;/h3&gt;
&lt;p&gt;Las dos observaciones de Mike son el mismo invariante visto desde dos ejes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Temporal&lt;/strong&gt;: bytes del pasado frente al código del futuro.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;De concurrencia&lt;/strong&gt;: el escritor del presente frente al restaurador.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Y hay una forma de leerlo que lo unifica: &lt;strong&gt;restaurar no es copiar ficheros, es una transición de estado.&lt;/strong&gt; Cuando lo tratas como tal, el diseño se ordena solo — aparecen los guardarraíles, aparece el &lt;code&gt;page_size&lt;/code&gt;, aparece el rechazo honesto — y el resto es escribir el test que lo demuestre.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Un test verde hoy es una promesa. Un test verde dentro de un año es una garantía.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;gracias&quot;&gt;Gracias&lt;/h3&gt;
&lt;p&gt;Otra vez, el mejor &lt;em&gt;code review&lt;/em&gt; de Aethernet vino de la comunidad. Gracias, &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/Mike%20Dabydeen&quot;&gt;&lt;strong&gt;@Mike Dabydeen&lt;/strong&gt;&lt;/a&gt;: no señalaste un bug, señalaste un punto ciego. Primero &lt;em&gt;el tiempo&lt;/em&gt;, después &lt;em&gt;la primitiva&lt;/em&gt;. Ambas correcciones están en la &lt;code&gt;main&lt;/code&gt; por tu lectura.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusi-n-objetiva&quot;&gt;Conclusión objetiva&lt;/h3&gt;
&lt;p&gt;La versión &lt;strong&gt;1.3.0&lt;/strong&gt; de Aethernet incorpora ahora una garantía &lt;strong&gt;hacia adelante&lt;/strong&gt;: un paquete real conservado como &lt;em&gt;golden file&lt;/em&gt; que CI restaura en cada versión, un generador reproducible para ampliar la escalera y una restauración que usa el backup inverso de SQLite con &lt;em&gt;fallback&lt;/em&gt; seguro. Todo con &lt;code&gt;ruff&lt;/code&gt; + &lt;strong&gt;&lt;code&gt;mypy --strict&lt;/code&gt; (0 errores)&lt;/strong&gt; + &lt;code&gt;vulture&lt;/code&gt; + &lt;strong&gt;177 tests&lt;/strong&gt; en verde (sin migración de esquema nueva: sigue en v5).&lt;/p&gt;
&lt;p&gt;No es una promesa de marketing: es un test que puedes leer y un comando que puedes ejecutar.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# ampliar la escalera al cambiar el esquema o el formato
python scripts/gen_backup_fixture.py

# la garantía hacia adelante, corriendo en CI
pytest tests/test_backup_compat.py

# restaurar una copia vieja (aquí o en otra máquina)
aethernet restore aethernet-1.3.0-schema5.tar.gz
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;y-t-&quot;&gt;Y tú&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;¿Cómo verificas hoy que un backup de tu software seguirá restaurándose dentro de un año?&lt;/strong&gt; ¿Confías en la compatibilidad hacia adelante de tus formatos o, como yo, creías tener un test que en realidad solo probaba el presente? ¿Y hasta dónde llevas la primitiva: ficheros, transacciones o capas más altas? Cuéntamelo en los comentarios; leo y respondo a todo.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;sub&gt;&lt;strong&gt;English version below&lt;/strong&gt; · El original está en español. If you're an English speaker, scroll down to the translation and tell me how you verify your backups in the discussion. I reply to everyone.&lt;/sub&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;hr&gt;
&lt;h2 id=&quot;will-your-backup-survive-the-next-version-the-test-i-was-missing&quot;&gt;Will your backup survive the next version? The test I was missing&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Series:&lt;/strong&gt; &lt;em&gt;Auditable software&lt;/em&gt; — Post 02.1&lt;br&gt;
&lt;strong&gt;Read time:&lt;/strong&gt; ~9 min · &lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;python&lt;/code&gt; &lt;code&gt;database&lt;/code&gt; &lt;code&gt;testing&lt;/code&gt; &lt;code&gt;opensource&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;i-thought-i-had-closed-the-problem&quot;&gt;I thought I had closed the problem&lt;/h3&gt;
&lt;p&gt;There's a kind of calm that isn't engineering: the feeling of having finished something because it no longer hurts. &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/30129/tu-backup-no-sirve-si-no-puedes-restaurarlo-el-wal-que-vacio-mi-base&quot;&gt;Post 02&lt;/a&gt; ended with a line I liked —&lt;em&gt;recovering is the job&lt;/em&gt;— and with a test that restored a copy on a clean machine. I called it done.&lt;/p&gt;
&lt;p&gt;It wasn't. I had confused &lt;strong&gt;my&lt;/strong&gt; calm with a guarantee.&lt;/p&gt;
&lt;p&gt;This isn't about fixing an &lt;code&gt;if&lt;/code&gt;. It's a failure of perspective: I looked at the system along the axis of &lt;em&gt;space&lt;/em&gt; (does it work on another machine?) and forgot the axis of &lt;em&gt;time&lt;/em&gt; (will it still work once the software has changed?). A backup is, by definition, a promise made to the future. And I was validating it only in the present.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-comment-that-looked-where-i-didn-t&quot;&gt;The comment that looked where I didn't&lt;/h3&gt;
&lt;p&gt;Mike Dabydeen, a community leader, read the post and wrote what I hadn't wanted to see. His central point: my test proved that &lt;strong&gt;today's copy restores with today's code&lt;/strong&gt;. Exactly the case nobody needs. Whoever truly depends on a backup wants to restore a copy from a year ago with the version that exists a year from now.&lt;/p&gt;
&lt;p&gt;I quote his two ideas, because both changed the design:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;The clean-machine test proves that today's copy restores with today's code. What it still doesn't cover is that a copy made with 1.3.0 restores with the version you publish a year from now — and that is exactly the case of whoever truly needs their backup. I'd keep a real package generated by 1.3.0 in the repo as an artifact and restore it in CI on every version.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;About the &lt;code&gt;-wal&lt;/code&gt; error, there's another option: restore using the same SQLite backup API, but in reverse... the copy keeps a write transaction open on the destination for the whole process and rolls it back if it doesn't finish, and the &lt;code&gt;-wal&lt;/code&gt; it finds is that same database's, so there are no orphan files to delete.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;He was right on both. And the first one stings more, because it isn't a bug: it's a &lt;strong&gt;promise with no witness&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;act-i-time-as-a-witness&quot;&gt;Act I — Time as a witness&lt;/h3&gt;
&lt;h4 id=&quot;the-trap-of-the-test-that-passes&quot;&gt;The trap of the test that passes&lt;/h4&gt;
&lt;p&gt;My recovery test was honest and still insufficient. It did this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# The test that proved the present
bundle = create_backup(source)        # generated with today's code
restore_backup(bundle, clean)         # and restored with today's code
assert count_scans(clean) == 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generation and restore used &lt;strong&gt;the same code&lt;/strong&gt;. A test closed in on itself: if tomorrow I change the schema and break the restore path, this test would stay green while users' copies stopped restoring. The proof of my promise was the promise looking at itself in a mirror.&lt;/p&gt;
&lt;p&gt;A promise is only real if it outlives the one who made it.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-a-ladder-of-fixtures&quot;&gt;The fix: a ladder of fixtures&lt;/h4&gt;
&lt;p&gt;I froze a &lt;strong&gt;real package&lt;/strong&gt; generated by 1.3.0 and committed it as a &lt;em&gt;golden file&lt;/em&gt;. On every version, CI must restore it on a clean machine:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tests/fixtures/backups/
├── aethernet-1.3.0-schema5.tar.gz          # the real package, ~5 KB
├── aethernet-1.3.0-schema5.expected.json   # its provenance and assertions
└── README.md                               # how to extend the ladder
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;.expected.json&lt;/code&gt; stores what makes the test verifiable: &lt;code&gt;sqlite_version&lt;/code&gt;, &lt;code&gt;page_size&lt;/code&gt;, &lt;code&gt;db_sha256&lt;/code&gt; and the counts that must survive (scans, devices, events). The test walks &lt;strong&gt;every&lt;/strong&gt; package in the folder, not one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.parametrize(&quot;bundle&quot;, sorted(FIXTURES.glob(&quot;*.tar.gz&quot;)), ids=lambda p: p.name)
def test_historical_backup_restores_on_clean_machine(bundle, tmp_path):
    expected = json.loads(_expected_path(bundle).read_text())
    result = restore_backup(bundle, _clean_paths(tmp_path))     # clean machine
    repo = Repository(Database(result.db_path))
    assert repo.counts()[&quot;wifi_scans&quot;] == expected[&quot;counts&quot;][&quot;wifi_scans&quot;]
    # ...report regenerated, config restored, schema migrated to v5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The maintenance rule was written by Mike himself: &lt;strong&gt;when the schema or the format changes, add a new package and keep the previous ones&lt;/strong&gt;. I automated it in a generator, &lt;code&gt;scripts/gen_backup_fixture.py&lt;/code&gt;, that always uses the current code and &lt;strong&gt;freezes clock and hostname&lt;/strong&gt; so the artifact doesn't leak the machine that built it.&lt;/p&gt;
&lt;p&gt;Here's the key part: &lt;code&gt;MIGRATIONS&lt;/code&gt; is &lt;em&gt;append-only&lt;/em&gt;, so a v5 copy migrates itself forward. The ladder stops being a promise in a &lt;code&gt;CHANGELOG&lt;/code&gt; and becomes &lt;strong&gt;a test that fails the day someone breaks a migration&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;act-ii-the-elegance-of-doing-less&quot;&gt;Act II — The elegance of doing less&lt;/h3&gt;
&lt;h4 id=&quot;from-defensive-deletion-to-the-right-primitive&quot;&gt;From defensive deletion to the right primitive&lt;/h4&gt;
&lt;p&gt;In Post 02 I fixed the &lt;code&gt;-wal&lt;/code&gt; that wiped the restored database by &lt;strong&gt;deleting&lt;/strong&gt; the orphan sidecar files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;_remove_sidecars(paths.db_path)     # drop the -wal/-shm
os.replace(staged_db, paths.db_path)
_remove_sidecars(paths.db_path)     # and again, just in case
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It works, but it's brute force: I clean up the mess something else left. Mike suggested dropping one level and using &lt;strong&gt;SQLite's own primitive, in reverse&lt;/strong&gt;. Instead of renaming a file and hoping about the sidecars, I let SQLite &lt;em&gt;write&lt;/em&gt; the prepared database &lt;strong&gt;into&lt;/strong&gt; the destination:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _replace_db(staged_db: Path, target: Path) -&amp;gt; None:
    # Reverse backup: source -&amp;gt; destination. SQLite opens a write
    # transaction on the destination and rolls it back if it doesn't finish.
    if _page_size(target) in (0, _page_size(staged_db)):
        try:
            _consistent_copy(staged_db, target)
            return
        except sqlite3.OperationalError as exc:
            if &quot;locked&quot; in str(exc).lower() or &quot;busy&quot; in str(exc).lower():
                raise BackupError(&quot;destination database is in use; stop the daemon before restoring&quot;)
    # Fallback: atomic replace with sidecar cleanup
    _remove_sidecars(target)
    os.replace(staged_db, target)
    _remove_sidecars(target)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The advantages are the ones he described: there's no window where the database doesn't exist, no orphan &lt;code&gt;-wal&lt;/code&gt;/&lt;code&gt;-shm&lt;/code&gt; remain (the WAL that shows up belongs to the database itself), and if something fails halfway, the destination &lt;strong&gt;rolls back&lt;/strong&gt;. I verified the behavior with an experiment before writing a single line: the reverse copy onto a WAL database with a live connection leaves the data correct and the orphan sidecar gone.&lt;/p&gt;
&lt;p&gt;There's elegance in doing less and trusting the engine that already knows its own invariants.&lt;/p&gt;
&lt;h4 id=&quot;the-condition-you-can-t-ignore&quot;&gt;The condition you can't ignore&lt;/h4&gt;
&lt;p&gt;The reverse backup has a real requirement: if the destination is in WAL mode, &lt;strong&gt;both files must share the same &lt;code&gt;page_size&lt;/code&gt;&lt;/strong&gt;. If they don't, SQLite aborts with an &lt;code&gt;OperationalError&lt;/code&gt;. That turns it into a design constraint, not a footnote:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the &lt;code&gt;page_size&lt;/code&gt; &lt;strong&gt;matches&lt;/strong&gt; → reverse backup (clean, with rollback).&lt;/li&gt;
&lt;li&gt;If it &lt;strong&gt;doesn't match&lt;/strong&gt; → atomic &lt;em&gt;fallback&lt;/em&gt; keeping the sidecar cleanup.&lt;/li&gt;
&lt;li&gt;If the destination is &lt;strong&gt;locked&lt;/strong&gt; by a live writer → &lt;strong&gt;abort with a warning&lt;/strong&gt;, instead of racing for the file and corrupting it silently.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last case matters most to the user: &lt;code&gt;restore --force&lt;/code&gt; over an active daemon is no longer a coin flip. If I can't guarantee the transition, &lt;strong&gt;I'd rather say no&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-this-changes-takeaway-&quot;&gt;What this changes (Takeaway)&lt;/h3&gt;
&lt;p&gt;Mike's two observations are the same invariant seen from two axes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Temporal&lt;/strong&gt;: bytes from the past against the code of the future.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concurrency&lt;/strong&gt;: the writer of the present against the restorer.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And there's a way to read it that unifies both: &lt;strong&gt;restoring isn't copying files, it's a state transition.&lt;/strong&gt; When you treat it as such, the design sorts itself out — the guardrails appear, the &lt;code&gt;page_size&lt;/code&gt; appears, the honest refusal appears — and all that's left is to write the test that proves it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A green test today is a promise. A green test a year from now is a guarantee.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;thanks&quot;&gt;Thanks&lt;/h3&gt;
&lt;p&gt;Once again, the best &lt;em&gt;code review&lt;/em&gt; of Aethernet came from the community. Thank you, &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/Mike%20Dabydeen&quot;&gt;&lt;strong&gt;@Mike Dabydeen&lt;/strong&gt;&lt;/a&gt;: you didn't point at a bug, you pointed at a blind spot. First &lt;em&gt;time&lt;/em&gt;, then &lt;em&gt;the primitive&lt;/em&gt;. Both fixes are on &lt;code&gt;main&lt;/code&gt; because of your read.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;objective-conclusion&quot;&gt;Objective conclusion&lt;/h3&gt;
&lt;p&gt;Version &lt;strong&gt;1.3.0&lt;/strong&gt; of Aethernet now ships a &lt;strong&gt;forward&lt;/strong&gt; guarantee: a real package kept as a &lt;em&gt;golden file&lt;/em&gt; that CI restores on every version, a reproducible generator to extend the ladder, and a restore that uses SQLite's reverse backup with a safe &lt;em&gt;fallback&lt;/em&gt;. All of it with &lt;code&gt;ruff&lt;/code&gt; + &lt;strong&gt;&lt;code&gt;mypy --strict&lt;/code&gt; (0 errors)&lt;/strong&gt; + &lt;code&gt;vulture&lt;/code&gt; + &lt;strong&gt;177 tests&lt;/strong&gt; green (no new schema migration: still v5).&lt;/p&gt;
&lt;p&gt;It isn't a marketing promise: it's a test you can read and a command you can run.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# extend the ladder when the schema or format changes
python scripts/gen_backup_fixture.py

# the forward guarantee, running in CI
pytest tests/test_backup_compat.py

# restore an old copy (here or on another machine)
aethernet restore aethernet-1.3.0-schema5.tar.gz
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;over-to-you&quot;&gt;Over to you&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How do you verify today that a backup of your software will still restore a year from now?&lt;/strong&gt; Do you trust your formats' forward compatibility, or — like me — did you believe you had a test that was really only proving the present? And how far down do you take the primitive: files, transactions, or higher layers? Tell me in the comments; I read and reply to everyone.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;sub&gt;Project license: MIT · No secrets or keys in this post. · Projects: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/yosoyignicion&quot;&gt;github.com/yosoyignicion&lt;/a&gt; · Aethernet code: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/yosoyignicion/Aethernet&quot;&gt;github.com/yosoyignicion/Aethernet&lt;/a&gt; · Release &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/yosoyignicion/Aethernet/releases/tag/v1.3.0&quot;&gt;v1.3.0&lt;/a&gt;&lt;/sub&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30139/tu-backup-sobrevivira-a-la-proxima-version-la-prueba-que-me-faltaba</guid>
<pubDate>Wed, 07 Oct 2026 13:00:43 +0000</pubDate>
</item>
<item>
<title>Tu backup no sirve si no puedes restaurarlo: el -wal que vació mi base</title>
<link>https://coderlegion.com/30129/tu-backup-no-sirve-si-no-puedes-restaurarlo-el-wal-que-vacio-mi-base</link>
<description>&lt;h2 id=&quot;tu-backup-no-sirve-si-no-puedes-restaurarlo-el--wal-que-vaci-mi-base&quot;&gt;Tu backup no sirve si no puedes restaurarlo: el &lt;code&gt;-wal&lt;/code&gt; que vació mi base&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Serie:&lt;/strong&gt; &lt;em&gt;Software auditable&lt;/em&gt; — Post 02&lt;br&gt;
&lt;strong&gt;Read time:&lt;/strong&gt; ~10-12 · &lt;strong&gt;Etiquetas:&lt;/strong&gt; &lt;code&gt;python&lt;/code&gt; &lt;code&gt;database&lt;/code&gt; &lt;code&gt;testing&lt;/code&gt; &lt;code&gt;opensource&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;la-confesi-n-un-bot-n-que-dec-a-respaldo-y-no-garantizaba-nada&quot;&gt;La confesión: un botón que decía &quot;RESPALDO&quot; y no garantizaba nada&lt;/h3&gt;
&lt;p&gt;Tengo una regla que me ha costado aprender: &lt;strong&gt;desconfía de tus propias features hasta que puedas demostrarlas&lt;/strong&gt;. Durante un tiempo, Aethernet tuvo un botón grande, verde y tranquilizador que decía &lt;code&gt;RESPALDO .TAR.GZ&lt;/code&gt;. Lo pulsabas, salía un archivo, y te ibas a dormir tranquilo. No garantizaba absolutamente nada.&lt;/p&gt;
&lt;p&gt;Ese botón es el punto de partida de este post. No porque estuviera roto —creaba un archivo, sí—, sino porque &lt;strong&gt;parecía una promesa y era una hipótesis sin comprobar&lt;/strong&gt;: nunca nadie había restaurado esa copia en otro equipo. Yo no había hecho la única pregunta que importa: ¿funciona cuando la necesitas?&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;el-feedback-que-lo-puso-por-escrito&quot;&gt;El feedback que lo puso por escrito&lt;/h3&gt;
&lt;p&gt;Un amigo de la comunidad me devolvió, con ayuda de su modelo agéntico como copiloto, un análisis que resumía el problema mejor que yo. Su idea central cabe en una frase: &lt;strong&gt;exportar no es recuperar&lt;/strong&gt;. Y me propuso un criterio de aceptación concreto: una prueba de recuperación &lt;em&gt;sin conexión&lt;/em&gt;, en una máquina limpia, sin acceso a la instalación original, en la que el usuario pueda examinar el historial y generar un informe. &quot;La capacidad de exportación y la capacidad de recuperación son criterios de aceptación distintos.&quot;&lt;/p&gt;
&lt;p&gt;Tenía razón, y además señalaba el hueco exacto de mi proyecto: no existía un procedimiento de recuperación documentado que cubriera base de datos, configuración y versión de esquema, ni claridad sobre qué claves había que guardar aparte.&lt;/p&gt;
&lt;p&gt;Ese comentario convirtió una intuición en una &lt;strong&gt;lista de tareas verificables&lt;/strong&gt;. Lo demás de este post es cómo lo cerré.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;el-problema-en-el-c-digo&quot;&gt;El problema, en el código&lt;/h3&gt;
&lt;p&gt;Mi respaldo era literalmente esto:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# El botón honesto y equivocado
target = paths.data_dir / f&quot;aethernet-backup-{ts}.tar.gz&quot;
with tarfile.open(target, &quot;w:gz&quot;) as tar:
    tar.add(paths.db_path, arcname=&quot;aethernet.db&quot;)   # solo la base
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tiene cuatro problemas, y los cuatro importan:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Solo copia la base.&lt;/strong&gt; La configuración (&lt;code&gt;my_ssids&lt;/code&gt;, alias, retención…) se queda fuera.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No registra la versión del esquema.&lt;/strong&gt; Al restaurar, no sabrías si la copia es más vieja o más nueva que la app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Copia un fichero vivo.&lt;/strong&gt; La base corre en modo &lt;strong&gt;WAL&lt;/strong&gt;; copiar el &lt;code&gt;.db&lt;/code&gt; a pelo mientras hay escrituras puede darte un estado incompleto.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No hay restore.&lt;/strong&gt; Un backup sin restauración es un export con buenos modales.&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;&lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Exportar &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Recuperar&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Pregunta &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;¿se leen bien los datos? &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;¿arranco como si nada hubiera pasado?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Máquina &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;esta, con la app instalada &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;limpia, sin nada de la original&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Prueba &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;abrir el informe &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;restaurar y volver a medir&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;la-soluci-n-en-cuatro-piezas&quot;&gt;La solución, en cuatro piezas&lt;/h3&gt;
&lt;h4 id=&quot;1-un-paquete-que-se-puede-inspeccionar&quot;&gt;1) Un paquete que se puede inspeccionar&lt;/h4&gt;
&lt;p&gt;Un único &lt;code&gt;.tar.gz&lt;/code&gt; con tres piezas y un manifiesto legible:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;app&quot;: &quot;aethernet&quot;,
  &quot;format_version&quot;: 1,
  &quot;app_version&quot;: &quot;1.3.0&quot;,
  &quot;schema_version&quot;: 5,
  &quot;created_at&quot;: 1759800000.0,
  &quot;db_sha256&quot;: &quot;9f2c…&quot;,
  &quot;members&quot;: [&quot;aethernet.db&quot;, &quot;config.toml&quot;, &quot;manifest.json&quot;],
  &quot;secrets_required&quot;: []
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ese &lt;code&gt;secrets_required: []&lt;/code&gt; no es decoración: es una declaración de diseño. &lt;strong&gt;No hay claves irrecuperables que guardar aparte.&lt;/strong&gt; El token de la API se regenera y nunca viaja en el paquete.&lt;/p&gt;
&lt;h4 id=&quot;2-copia-consistente-y-por-qu-no-un-cp-&quot;&gt;2) Copia consistente (y por qué no un &lt;code&gt;cp&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;Con WAL activo, copiar el fichero de la base es una apuesta. La forma correcta es dejar que SQLite haga la copia:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;src = sqlite3.connect(str(db_path))
dst = sqlite3.connect(str(staged_db))
src.backup(dst)          # instantánea consistente, aunque haya un escritor vivo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Connection.backup()&lt;/code&gt; fusiona el WAL en un único fichero coherente. Es stdlib: cero dependencias nuevas.&lt;/p&gt;
&lt;h4 id=&quot;3-restaurar-sin-romper-nada&quot;&gt;3) Restaurar sin romper nada&lt;/h4&gt;
&lt;p&gt;El orden importa, y cada paso existe por un fallo real:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Validar antes de escribir&lt;/strong&gt; — SHA-256 del manifiesto, miembros obligatorios y rutas seguras (nada de &lt;code&gt;../&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rechazar el futuro&lt;/strong&gt; — si la copia usa un esquema más nuevo que el binario, se aborta con un mensaje claro.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resguardar lo actual&lt;/strong&gt; — la base presente se copia a &lt;code&gt;aethernet.db.bak-&amp;lt;fecha&amp;gt;&lt;/code&gt; antes de tocarla.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrar hacia adelante&lt;/strong&gt; — las migraciones son &lt;em&gt;append-only&lt;/em&gt;, así que una copia más antigua se actualiza sola al restaurar.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;4-el-criterio-convertido-en-test&quot;&gt;4) El criterio convertido en test&lt;/h4&gt;
&lt;p&gt;Aquí está el corazón del asunto. El criterio de aceptación del comentario dejó de ser una opinión y pasó a ser código que puede fallar:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_offline_recovery_on_clean_machine(tmp_path):
    # 1. origen: historial + ajustes
    source = _paths(tmp_path, &quot;origin&quot;)
    ...  # se guardan escaneos y config
    bundle = create_backup(source, tmp_path / &quot;backup.tar.gz&quot;)

    # 2. máquina limpia: otra instalación, sin nada de la original
    clean = _paths(tmp_path, &quot;clean&quot;)
    restore_backup(bundle.path, clean)

    # 3. ¿se lee el historial y se genera un informe desde cero?
    repo = Repository(Database(clean.db_path))
    assert repo.wifi_scan_count() == 2
    report = build_report_data(repo, load_settings(clean))
    export(report, &quot;md&quot;, clean.report_dir / &quot;recovery.md&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Si este test se pone en rojo, mi backup &lt;strong&gt;no existe&lt;/strong&gt;. No hay interpretación: o recupera, o no recupera.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;el-bug-el--wal-que-vaci-mi-base-restaurada&quot;&gt;El bug: el &lt;code&gt;-wal&lt;/code&gt; que vació mi base restaurada&lt;/h3&gt;
&lt;p&gt;La primera versión del restore pasaba los tests unitarios… y fallaba el de recuperación real. Restauraba una copia con datos y la base aparecía &lt;strong&gt;vacía&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;La causa raíz es una lección de SQLite que me llevé grabada: al reemplazar &lt;code&gt;aethernet.db&lt;/code&gt; con un &lt;code&gt;os.replace&lt;/code&gt;, los ficheros auxiliares &lt;code&gt;aethernet.db-wal&lt;/code&gt; y &lt;code&gt;aethernet.db-shm&lt;/code&gt; &lt;strong&gt;se quedan ahí&lt;/strong&gt;. Al reabrir la base restaurada, SQLite encuentra un WAL que pertenece a &lt;em&gt;otra&lt;/em&gt; base, lo &lt;strong&gt;reaplica&lt;/strong&gt; y sobrescribe tus datos con el estado anterior.&lt;/p&gt;
&lt;p&gt;El arreglo va en dos capas, porque el fallo tenía dos puertas:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;_remove_sidecars(paths.db_path)          # fuera el -wal/-shm huérfano
os.replace(staged_db, paths.db_path)
_remove_sidecars(paths.db_path)          # y otra vez, por si acaso
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;…y en el CLI, &lt;strong&gt;cerrar la conexión del contexto antes de restaurar&lt;/strong&gt;, porque no se restaura sobre una base abierta. Añadí un test que restaura &lt;em&gt;sobre una conexión WAL viva&lt;/em&gt; para que no vuelva a pasar:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;live = Database(paths.db_path); live.migrate()
restore_backup(bundle.path, paths)       # antes: 0 escaneos. ahora: 1.
assert Repository(Database(paths.db_path)).wifi_scan_count() == 1
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;el-otro-bug-que-no-ves-hasta-que-cambias-de-disco&quot;&gt;El otro bug que no ves hasta que cambias de disco&lt;/h3&gt;
&lt;p&gt;Con el test en verde, probé en un disco distinto y apareció &lt;code&gt;OSError: Invalid cross-device link&lt;/code&gt;. El &lt;em&gt;staging&lt;/em&gt; vivía en &lt;code&gt;/tmp&lt;/code&gt; (a menudo &lt;code&gt;tmpfs&lt;/code&gt;) y el &lt;code&gt;os.replace&lt;/code&gt; final apuntaba a &lt;code&gt;~/.local/share&lt;/code&gt;, en otro sistema de ficheros: un renombrado no puede cruzar discos.&lt;/p&gt;
&lt;p&gt;La corrección es de una línea pero de fondo: &lt;strong&gt;el staging vive junto al destino&lt;/strong&gt;, para que el reemplazo sea un renombrado atómico en el mismo sistema de ficheros. Para la configuración, un &lt;code&gt;shutil.move&lt;/code&gt; (que sí sabe cruzar discos). Lo verifiqué con la base en &lt;code&gt;tmpfs&lt;/code&gt; y el temporal en disco: recuperación correcta.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;lo-que-esto-cambia-takeaway--1&quot;&gt;Lo que esto cambia (Takeaway)&lt;/h3&gt;
&lt;p&gt;Un criterio de aceptación no es una opinión: es &lt;strong&gt;un test que puede fallar&lt;/strong&gt;. &quot;Tengo backup&quot; no significa nada hasta que has restaurado en una máquina limpia y has comprobado que el histórico y los informes siguen ahí. Y cuando el criterio se escribe como test, el diseño se ordena solo: aparece el manifiesto, aparece la copia consistente, aparecen los guardarraíles, y aparece el resto de la lista.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Exportar es fácil. Recuperar es el trabajo.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;gracias-1&quot;&gt;Gracias&lt;/h3&gt;
&lt;p&gt;Este post no existiría sin el análisis de un amigo de la comunidad, que con IA asistida convirtió una intuición mía en una lista de requisitos verificables. Su pregunta —&quot;¿puedes restaurar en una máquina limpia?&quot;— fue el mejor &lt;em&gt;code review&lt;/em&gt; que ha recibido Aethernet. Gracias de verdad.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusi-n-objetiva-1&quot;&gt;Conclusión objetiva&lt;/h3&gt;
&lt;p&gt;La versión &lt;strong&gt;1.3.0&lt;/strong&gt; de Aethernet incorpora una recuperación offline demostrable: paquete con base consistente, configuración y manifiesto versionado; restore que valida, resguarda y migra; &lt;code&gt;aethernet restore --verify&lt;/code&gt; para comprobar una copia sin escribir; y un test que reproduce la prueba en una instalación limpia. Todo con &lt;code&gt;ruff&lt;/code&gt; + &lt;strong&gt;&lt;code&gt;mypy --strict&lt;/code&gt; (0 errores)&lt;/strong&gt; + &lt;code&gt;vulture&lt;/code&gt; + &lt;strong&gt;173 tests&lt;/strong&gt; en verde y CI sobre Python 3.11 y 3.12 (sin migración de esquema nueva: el esquema sigue en v5).&lt;/p&gt;
&lt;p&gt;No es una promesa de marketing: es un comando que puedes ejecutar.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;aethernet backup                    # crea el paquete .tar.gz
aethernet restore --verify copia.tar.gz
aethernet restore copia.tar.gz      # recupérala en esta o en otra máquina
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;y-t--1&quot;&gt;Y tú&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;¿Cómo verificas hoy que tus copias de seguridad sirven?&lt;/strong&gt; ¿Alguna vez has restaurado un &quot;backup&quot; que no restauraba nada —y cómo lo descubriste? ¿Qué le exigirías a una herramienta para fiarte de su recuperación? Cuéntamelo en los comentarios; leo y respondo a todo.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;sub&gt;&lt;strong&gt;English version below&lt;/strong&gt; · El original está en español. If you're an English speaker, scroll down to the translation and tell me how you verify your backups in the discussion. I reply to everyone.&lt;/sub&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;hr&gt;
&lt;h2 id=&quot;your-backup-is-worthless-if-you-can-t-restore-it-the--wal-that-wiped-my-database&quot;&gt;Your backup is worthless if you can't restore it: the &lt;code&gt;-wal&lt;/code&gt; that wiped my database&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Series:&lt;/strong&gt; &lt;em&gt;Auditable software&lt;/em&gt; — Post 02&lt;br&gt;
&lt;strong&gt;Read time:&lt;/strong&gt; ~10-12 · &lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;python&lt;/code&gt; &lt;code&gt;database&lt;/code&gt; &lt;code&gt;testing&lt;/code&gt; &lt;code&gt;opensource&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-confession-a-button-that-said-backup-and-guaranteed-nothing&quot;&gt;The confession: a button that said &quot;BACKUP&quot; and guaranteed nothing&lt;/h3&gt;
&lt;p&gt;There's a rule I learned the hard way: &lt;strong&gt;distrust your own features until you can prove them&lt;/strong&gt;. For a while, Aethernet had a big, green, reassuring button that said &lt;code&gt;BACKUP .TAR.GZ&lt;/code&gt;. You pressed it, a file came out, and you went to sleep feeling safe. It guaranteed absolutely nothing.&lt;/p&gt;
&lt;p&gt;That button is where this post begins. Not because it was broken —it did create a file— but because &lt;strong&gt;it looked like a promise and was really an untested hypothesis&lt;/strong&gt;: no one had ever restored that copy on another machine. I hadn't asked the only question that matters: does it work when you need it?&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-feedback-that-put-it-in-writing&quot;&gt;The feedback that put it in writing&lt;/h3&gt;
&lt;p&gt;A friend from the community sent back an analysis —with an agentic model as co-pilot— that framed the problem better than I had. The core idea fits in one sentence: &lt;strong&gt;exporting is not recovering&lt;/strong&gt;. And he proposed a concrete acceptance criterion: an &lt;em&gt;offline&lt;/em&gt; recovery test on a clean machine, with no access to the original install, where the user can still inspect the history and generate a report.&lt;/p&gt;
&lt;p&gt;His exact words:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;I'd add an offline recovery test: can a user restore a backup on a clean machine —with no access to the original install— and still inspect the history and generate a report? Export capability and recovery capability are distinct acceptance criteria.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;He was right, and he also pointed at my project's exact gap: there was no documented recovery procedure covering the database, the configuration and the schema version, nor any clarity about which keys had to be handed over separately. That comment turned an intuition into a &lt;strong&gt;list of verifiable tasks&lt;/strong&gt;. The rest of this post is how I closed it. Thank you, &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/@lisarconnect&quot;&gt;&lt;code&gt;@lisarconnect&lt;/code&gt;&lt;/a&gt;, for the AI-assisted analysis.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem-in-code&quot;&gt;The problem, in code&lt;/h3&gt;
&lt;p&gt;My backup was literally this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# The honest, wrong button
target = paths.data_dir / f&quot;aethernet-backup-{ts}.tar.gz&quot;
with tarfile.open(target, &quot;w:gz&quot;) as tar:
    tar.add(paths.db_path, arcname=&quot;aethernet.db&quot;)   # the database only
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four problems, and all four matter:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;It only copies the database.&lt;/strong&gt; The configuration (&lt;code&gt;my_ssids&lt;/code&gt;, aliases, retention…) is left out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It records no schema version.&lt;/strong&gt; On restore, you wouldn't know if the copy is older or newer than the app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It copies a live file.&lt;/strong&gt; The database runs in &lt;strong&gt;WAL&lt;/strong&gt; mode; copying the &lt;code&gt;.db&lt;/code&gt; by hand while writes are happening can give you an incomplete state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There's no restore.&lt;/strong&gt; A backup without a restore is an export with good manners.&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;&lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Export &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Recover&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Question &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;do the data read back? &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;do I boot up as if nothing happened?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Machine &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;this one, app installed &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;clean, with nothing from the original&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Test &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;open the report &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;restore and measure again&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-solution-in-four-pieces&quot;&gt;The solution, in four pieces&lt;/h3&gt;
&lt;h4 id=&quot;1-a-package-you-can-inspect&quot;&gt;1) A package you can inspect&lt;/h4&gt;
&lt;p&gt;A single &lt;code&gt;.tar.gz&lt;/code&gt; with three parts and a readable manifest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;app&quot;: &quot;aethernet&quot;,
  &quot;format_version&quot;: 1,
  &quot;app_version&quot;: &quot;1.3.0&quot;,
  &quot;schema_version&quot;: 5,
  &quot;created_at&quot;: 1759800000.0,
  &quot;db_sha256&quot;: &quot;9f2c…&quot;,
  &quot;members&quot;: [&quot;aethernet.db&quot;, &quot;config.toml&quot;, &quot;manifest.json&quot;],
  &quot;secrets_required&quot;: []
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;secrets_required: []&lt;/code&gt; isn't decoration: it's a design statement. &lt;strong&gt;There are no irreplaceable keys to hand over.&lt;/strong&gt; The API token is regenerated and never travels in the package.&lt;/p&gt;
&lt;h4 id=&quot;2-a-consistent-copy-and-why-not-cp-&quot;&gt;2) A consistent copy (and why not &lt;code&gt;cp&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;With WAL active, copying the database file is a gamble. The right way is to let SQLite make the copy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;src = sqlite3.connect(str(db_path))
dst = sqlite3.connect(str(staged_db))
src.backup(dst)          # consistent snapshot, even with a live writer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Connection.backup()&lt;/code&gt; folds the WAL into a single coherent file. It's stdlib: no new dependencies.&lt;/p&gt;
&lt;h4 id=&quot;3-restore-without-breaking-anything&quot;&gt;3) Restore without breaking anything&lt;/h4&gt;
&lt;p&gt;Order matters, and every step exists because of a real failure:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Validate before writing&lt;/strong&gt; — manifest SHA-256, required members and safe paths (no &lt;code&gt;../&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reject the future&lt;/strong&gt; — if the copy uses a newer schema than the binary, it aborts with a clear message.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Protect the present&lt;/strong&gt; — the current database is copied to &lt;code&gt;aethernet.db.bak-&amp;lt;date&amp;gt;&lt;/code&gt; before touching it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate forward&lt;/strong&gt; — migrations are &lt;em&gt;append-only&lt;/em&gt;, so an older copy upgrades itself on restore.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;4-the-criterion-turned-into-a-test&quot;&gt;4) The criterion turned into a test&lt;/h4&gt;
&lt;p&gt;This is the heart of it. The comment's acceptance criterion stopped being an opinion and became code that can fail:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_offline_recovery_on_clean_machine(tmp_path):
    # 1. origin: history + settings
    source = _paths(tmp_path, &quot;origin&quot;)
    ...  # scans and config are stored
    bundle = create_backup(source, tmp_path / &quot;backup.tar.gz&quot;)

    # 2. clean machine: another install, with nothing from the original
    clean = _paths(tmp_path, &quot;clean&quot;)
    restore_backup(bundle.path, clean)

    # 3. does it read the history and generate a report from scratch?
    repo = Repository(Database(clean.db_path))
    assert repo.wifi_scan_count() == 2
    report = build_report_data(repo, load_settings(clean))
    export(report, &quot;md&quot;, clean.report_dir / &quot;recovery.md&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If this test goes red, my backup &lt;strong&gt;does not exist&lt;/strong&gt;. No interpretation: either it recovers, or it doesn't.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-bug-the--wal-that-wiped-my-restored-database&quot;&gt;The bug: the &lt;code&gt;-wal&lt;/code&gt; that wiped my restored database&lt;/h3&gt;
&lt;p&gt;The first version of restore passed the unit tests… and failed the real recovery one. It restored a copy with data and the database came up &lt;strong&gt;empty&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The root cause is a SQLite lesson I won't forget: when you replace &lt;code&gt;aethernet.db&lt;/code&gt; with &lt;code&gt;os.replace&lt;/code&gt;, the sidecar files &lt;code&gt;aethernet.db-wal&lt;/code&gt; and &lt;code&gt;aethernet.db-shm&lt;/code&gt; &lt;strong&gt;stay behind&lt;/strong&gt;. When SQLite reopens the restored database, it finds a WAL that belongs to &lt;em&gt;another&lt;/em&gt; database, &lt;strong&gt;replays it&lt;/strong&gt;, and overwrites your data with the previous state.&lt;/p&gt;
&lt;p&gt;The fix comes in two layers, because the failure had two doors:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;_remove_sidecars(paths.db_path)          # drop the orphan -wal/-shm
os.replace(staged_db, paths.db_path)
_remove_sidecars(paths.db_path)          # and again, just in case
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;…and in the CLI, &lt;strong&gt;closing the context connection before restoring&lt;/strong&gt;, because you don't restore over an open database. I added a test that restores &lt;em&gt;over a live WAL connection&lt;/em&gt; so it never happens again:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;live = Database(paths.db_path); live.migrate()
restore_backup(bundle.path, paths)       # before: 0 scans. now: 1.
assert Repository(Database(paths.db_path)).wifi_scan_count() == 1
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-other-bug-you-only-see-when-you-change-disks&quot;&gt;The other bug you only see when you change disks&lt;/h3&gt;
&lt;p&gt;With the test green, I tried another disk and hit &lt;code&gt;OSError: Invalid cross-device link&lt;/code&gt;. The &lt;em&gt;staging&lt;/em&gt; lived in &lt;code&gt;/tmp&lt;/code&gt; (often &lt;code&gt;tmpfs&lt;/code&gt;) and the final &lt;code&gt;os.replace&lt;/code&gt; pointed at &lt;code&gt;~/.local/share&lt;/code&gt;, on a different filesystem: a rename can't cross disks.&lt;/p&gt;
&lt;p&gt;The fix is a one-liner with real weight: &lt;strong&gt;staging lives next to the destination&lt;/strong&gt;, so the replacement is an atomic rename on the same filesystem. For the config, a &lt;code&gt;shutil.move&lt;/code&gt; (which does cross disks). I verified it with the database on &lt;code&gt;tmpfs&lt;/code&gt; and the temp on disk: recovery works.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-this-changes-takeaway--1&quot;&gt;What this changes (Takeaway)&lt;/h3&gt;
&lt;p&gt;An acceptance criterion isn't an opinion: it's &lt;strong&gt;a test that can fail&lt;/strong&gt;. &quot;I have a backup&quot; means nothing until you've restored it on a clean machine and checked that the history and the reports are still there. And once the criterion is written as a test, the design sorts itself out: the manifest appears, the consistent copy appears, the guardrails appear, and so does the rest of the list.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Exporting is easy. Recovering is the job.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;thanks-1&quot;&gt;Thanks&lt;/h3&gt;
&lt;p&gt;This post wouldn't exist without the analysis from a friend in the community, who —with AI assistance— turned an intuition of mine into a list of verifiable requirements. His question —&quot;can you restore on a clean machine?&quot;— was the best code review Aethernet has ever received. &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/@lisarconnect&quot;&gt;&lt;code&gt;@lisarconnect&lt;/code&gt;&lt;/a&gt;, thank you, sincerely.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;objective-conclusion-1&quot;&gt;Objective conclusion&lt;/h3&gt;
&lt;p&gt;Version &lt;strong&gt;1.3.0&lt;/strong&gt; of Aethernet ships a demonstrable offline recovery: a package with a consistent database, the configuration and a versioned manifest; a restore that validates, protects and migrates; &lt;code&gt;aethernet restore --verify&lt;/code&gt; to check a copy without writing; and a test that reproduces the clean-machine trial. All of it with &lt;code&gt;ruff&lt;/code&gt; + &lt;strong&gt;&lt;code&gt;mypy --strict&lt;/code&gt; (0 errors)&lt;/strong&gt; + &lt;code&gt;vulture&lt;/code&gt; + &lt;strong&gt;173 tests&lt;/strong&gt; green and CI on Python 3.11 and 3.12 (no new schema migration: the schema is still v5).&lt;/p&gt;
&lt;p&gt;It isn't a marketing promise: it's a command you can run.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;aethernet backup                    # create the .tar.gz package
aethernet restore --verify copy.tar.gz
aethernet restore copy.tar.gz       # recover it here or on another machine
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;over-to-you-1&quot;&gt;Over to you&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How do you verify today that your backups actually work?&lt;/strong&gt; Have you ever restored a &quot;backup&quot; that restored nothing —and how did you find out? What would you demand from a tool before trusting its recovery? Tell me in the comments; I read and reply to everyone.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;sub&gt;Project license: MIT · No secrets or keys in this post. · Projects: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/yosoyignicion&quot;&gt;github.com/yosoyignicion&lt;/a&gt; · Aethernet code: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/yosoyignicion/Aethernet&quot;&gt;github.com/yosoyignicion/Aethernet&lt;/a&gt; · Release &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/yosoyignicion/Aethernet/releases/tag/v1.3.0&quot;&gt;v1.3.0&lt;/a&gt;&lt;/sub&gt;&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/30129/tu-backup-no-sirve-si-no-puedes-restaurarlo-el-wal-que-vacio-mi-base</guid>
<pubDate>Wed, 07 Oct 2026 11:39:23 +0000</pubDate>
</item>
<item>
<title>Building a High-Speed School Marketplace (Part 2): Multi-Role Security with Supabase RLS</title>
<link>https://coderlegion.com/30070/building-a-high-speed-school-marketplace-part-2-multi-role-security-with-supabase-rls</link>
<description>&lt;p&gt;&lt;em&gt;This is Part 2 of our technical series on building a high-speed, lightweight school marketplace. In Part 1: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/juna_go15/building-a-high-speed-school-marketplace-why-we-ditched-frameworks-for-vanilla-js-pwa-27dl&quot;&gt;\[Why We Ditched Frameworks for Vanilla JS &amp;amp; PWA,\]&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/juna_go15/building-a-high-speed-school-marketplace-why-we-ditched-frameworks-for-vanilla-js-pwa-27dl&quot;&gt;1&lt;/a&gt; we discussed how we optimized the frontend for budget mobile devices. Today, we look at the backend architecture.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;When planning the architecture for &lt;strong&gt;Aksara-Mart&lt;/strong&gt;—our digital marketplace for vocational school canteens and cooperatives—we faced a fundamental infrastructure question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Do we really need to build, maintain, and scale a traditional custom backend server (like Node.js/Express, Python, or Go) just to shuttle data between the browser and the database?&lt;/strong&gt;&lt;br&gt;
For a school environment, maintaining a traditional backend comes with real trade-offs:&lt;br&gt;
1.&lt;strong&gt;Infrastructure &amp;amp; Hosting Overhead&lt;/strong&gt;: A dedicated server needs 24/7 uptime monitoring, memory management, and scaling configuration for peak recess traffic.&lt;br&gt;
2.&lt;strong&gt;Maintenance Burden&lt;/strong&gt;: API routes, middleware controllers, and ORM abstractions require continuous patching and maintenance.&lt;br&gt;
3.&lt;strong&gt;Unnecessary Latency&lt;/strong&gt;: Every simple query (browsing the lunch menu, checking an order status) has to make an extra hop through an intermediate API server layer.&lt;br&gt;
We wanted an architecture that could &lt;strong&gt;minimize backend server workload&lt;/strong&gt; while keeping running costs close to zero.&lt;br&gt;
That led us to &lt;strong&gt;Supabase&lt;/strong&gt;. By allowing our frontend JavaScript to communicate directly with PostgreSQL via auto-generated APIs and WebSockets, we could offload the bulk of routine read queries directly to the client.&lt;br&gt;
However, letting client-side JavaScript query a database directly sounds like a security nightmare—unless you have a bulletproof authorization layer built into the database itself.&lt;br&gt;
That is where &lt;strong&gt;PostgreSQL Row Level Security (RLS)&lt;/strong&gt; becomes the cornerstone of the entire system.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The Architecture: Direct Client Queries Without the Middleman&lt;br&gt;
In a traditional web application, every user request passes through three layers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;[ Browser (Client) ] ──▶ [ Custom API Server (Express/Node) ] ──▶ [ Database ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The API server spends most of its CPU cycles doing mundane tasks: authenticating tokens, verifying if a user is allowed to read a row, running the query, and formatting JSON.&lt;br&gt;
With Supabase and PostgreSQL RLS, we simplified the architecture into a direct, high-performance two-tier model:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;[ Browser (Vanilla JS Client) ] ──────────────▶ [ Supabase PostgreSQL + RLS ]
•Direct catalog SELECT                         - Kernel-level row authorization
•Direct order history lookup                   - Instant query evaluation (&amp;lt; 3ms)
•Real-time WebSocket subscriptions             - Atomic RPC stored procedures
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Advantages:&lt;br&gt;
•&lt;strong&gt;Zero Backend Server Maintenance&lt;/strong&gt;: No intermediate server processes to crash, restart, or run out of memory during a rush.&lt;br&gt;
•&lt;strong&gt;Minimal Compute Overhead&lt;/strong&gt;: PostgreSQL handles filtering and JSON serialization natively in C, which is vastly faster than parsing objects in Node.js.&lt;br&gt;
•&lt;strong&gt;Direct Client Queries&lt;/strong&gt;: Frontend JavaScript can fetch exactly the columns it needs, when it needs them, directly from the client.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Core Problem: Securing Direct Database Access&lt;br&gt;
If frontend JavaScript talks directly to the database using a public API key, what prevents a student from querying other users' private orders or inspecting canteen cost margins?&lt;br&gt;
This is where &lt;strong&gt;Row Level Security (RLS)&lt;/strong&gt; comes in.&lt;br&gt;
Instead of writing authorization logic in backend middleware (&lt;code&gt;if (req.user.id !== order.user_id) return res.status(403)&lt;/code&gt;), the authorization rules are compiled directly into PostgreSQL table definitions.&lt;br&gt;
•Without RLS: A client query &lt;code&gt;supabase.from('orders').select('*')&lt;/code&gt; returns every order in the database.&lt;br&gt;
•With RLS: PostgreSQL automatically injects an internal filter into the query plan:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Automatically enforced by PostgreSQL on every query
WHERE email_customers = auth.jwt() -&amp;gt;&amp;gt; 'email'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Even if a user inspects the network tab and writes a custom script asking for all records, the database engine guarantees they only receive the rows they are explicitly permitted to see.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Managing Roles Across the School Ecosystem&lt;br&gt;
A school marketplace serves several distinct groups with different permissions:&lt;br&gt;
| Role | Access Scope | How Queries Are Handled |&lt;br&gt;
| :--- | :--- | :--- |&lt;br&gt;
| &lt;strong&gt;&lt;code&gt;siswa&lt;/code&gt; (Students)&lt;/strong&gt; | Browse active products, view own order history. | Direct client &lt;code&gt;SELECT&lt;/code&gt; constrained by RLS. |&lt;br&gt;
| &lt;strong&gt;&lt;code&gt;guru&lt;/code&gt; (Teachers)&lt;/strong&gt; | Same as students, with priority fulfillment flags. | Direct client &lt;code&gt;SELECT&lt;/code&gt; constrained by RLS. |&lt;br&gt;
| &lt;strong&gt;&lt;code&gt;kasir&lt;/code&gt; (Cashiers)&lt;/strong&gt; | View all active incoming orders, print receipts. | Direct client &lt;code&gt;SELECT&lt;/code&gt; with staff privileges; no direct price/stock editing. |&lt;br&gt;
| &lt;strong&gt;&lt;code&gt;manajer&lt;/code&gt; (Managers)&lt;/strong&gt; | Manage catalog items, update stock, view revenue. | Full CRUD permissions on product tables via RLS. |&lt;br&gt;
| &lt;strong&gt;&lt;code&gt;admin&lt;/code&gt; (System Admins)&lt;/strong&gt; | System-wide configuration and role management. | Full administrative access. |&lt;br&gt;
The &lt;code&gt;admin_role&lt;/code&gt; Table&lt;br&gt;
To keep role checks clean and reliable, we maintain a dedicated role table in PostgreSQL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE public.admin_role (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
);
ALTER TABLE public.admin_role ENABLE ROW LEVEL SECURITY;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practical Gotchas We Addressed:&lt;br&gt;
4.&lt;strong&gt;Case-Insensitive Emails&lt;/strong&gt;: Mobile keyboards often capitalize the first letter of an email (&lt;code&gt;*Emails are not allowed*&lt;/code&gt;). Always use &lt;code&gt;lower(trim(email))&lt;/code&gt; in policies to prevent login lockouts.&lt;br&gt;
5.&lt;strong&gt;Dual-Spelling Tolerance&lt;/strong&gt;: In bilingual environments, staff may enter roles in English or Indonesian (&lt;code&gt;manager&lt;/code&gt; vs &lt;code&gt;manajer&lt;/code&gt;, &lt;code&gt;cashier&lt;/code&gt; vs &lt;code&gt;kasir&lt;/code&gt;). We check role aliases with &lt;code&gt;IN ('manager', 'manajer', 'cashier', 'kasir')&lt;/code&gt; so minor spelling differences don't break authentication.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Separation of Concerns: Direct Reads vs Server-Side Writes&lt;br&gt;
Allowing frontend JavaScript to query the database directly works brilliantly for &lt;strong&gt;reads&lt;/strong&gt;, but transactional &lt;strong&gt;writes&lt;/strong&gt; require a different boundary.&lt;br&gt;
A. Direct Client Reads (Safe via RLS)&lt;br&gt;
Frontend JavaScript directly reads from the database for:&lt;br&gt;
•&lt;strong&gt;The product catalog&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Direct client query in skripmart.js
const { data: products } = await supabase
.from('product_list')
.select('id, name, price, stock, image_url')
.eq('status', 'active');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RLS ensures that wholesale costs (&lt;code&gt;modal_price&lt;/code&gt;) and archived items are filtered out before data leaves PostgreSQL.&lt;br&gt;
•&lt;strong&gt;Order tracking&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Customers only receive their own orders
const { data: myOrders } = await supabase
.from('orders')
.select('*')
.order('created_at', { ascending: false });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;B. Controlled Server-Side Writes (Via Database RPC)&lt;br&gt;
While reading data directly from JavaScript is safe, &lt;strong&gt;creating orders and modifying stock should never happen via direct client &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ❌ ANTI-PATTERN: Never allow direct client inserts for financial transactions
await supabase.from('orders').insert({
total_price: clientCalculatedTotal, // Client could tamper with this value!
items: cartItems
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If we allowed direct client inserts on the &lt;code&gt;orders&lt;/code&gt; table, a user could theoretically alter prices or bypass stock limits.&lt;br&gt;
Instead, we follow a simple architectural rule:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Frontend JavaScript reads data directly using RLS.&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Frontend JavaScript writes transactions via PostgreSQL Stored Procedures (RPC).&lt;/strong&gt;&lt;br&gt;
When a student checks out, the client invokes an RPC procedure (&lt;code&gt;v2_create_order&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ✅ SECURE: Transaction logic runs atomically inside PostgreSQL
const { data, error } = await supabase.rpc('v2_create_order', {
p_customer_name: userProfile.name,
p_delivery_type: deliveryOption,
p_items: cartPayload
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside this procedure, the database recalculates prices from the official catalog, checks remaining stock, decrements inventory, and creates the order records in a single atomic transaction.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What We Gained from This Architecture&lt;br&gt;
By offloading query processing directly to the client and database engine:&lt;br&gt;
6.&lt;strong&gt;Zero Backend Maintenance&lt;/strong&gt;: We do not maintain or pay for a custom Node.js/Express server layer.&lt;br&gt;
7.&lt;strong&gt;Sub-3ms Authorization Overhead&lt;/strong&gt;: RLS policy checks happen natively within PostgreSQL's query optimizer with virtually no latency penalty.&lt;br&gt;
8.&lt;strong&gt;High Reliability During Traffic Spikes&lt;/strong&gt;: When hundreds of students browse the menu simultaneously during recess, Supabase and Cloudflare serve the requests directly without choking an intermediate web server.&lt;br&gt;
9.&lt;strong&gt;Complete Data Privacy&lt;/strong&gt;: Every customer and staff member only receives data that belongs to their assigned role.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;What's Next in Part 3?&lt;br&gt;
With direct reads secured by RLS and client-side queries running smoothly, we still had to solve one critical concurrency problem:&lt;br&gt;
During the 15-minute school break, &lt;strong&gt;dozens of students click &quot;Checkout&quot; at the exact same millisecond for the last few items in the canteen&lt;/strong&gt;.&lt;br&gt;
How do we prevent &lt;strong&gt;race conditions and overselling&lt;/strong&gt; without a heavy application server?&lt;br&gt;
&lt;strong&gt;In Part 3: Atomic Checkouts &amp;amp; Zero Overselling with PostgreSQL RPC&lt;/strong&gt;, we will break down how we implemented &lt;strong&gt;pessimistic row locking (&lt;code&gt;FOR UPDATE&lt;/code&gt;)&lt;/strong&gt; inside PostgreSQL to handle high-concurrency checkout spikes in &lt;strong&gt;under 42 milliseconds&lt;/strong&gt;!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30070/building-a-high-speed-school-marketplace-part-2-multi-role-security-with-supabase-rls</guid>
<pubDate>Wed, 07 Oct 2026 04:15:37 +0000</pubDate>
</item>
<item>
<title>Write C++ like it's SQL</title>
<link>https://coderlegion.com/30002/write-c-like-its-sql</link>
<description>&lt;p&gt;A lot of C/C++ developers build complex data structures as if database theory never happened. And then they spend months maintaining pointer contracts that a schema could have made obvious.&lt;/p&gt;
&lt;p&gt;Let’s take a graph structure as an example.&lt;/p&gt;
&lt;p&gt;A common pattern looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct Node;
struct Edge;

struct Node {
    uint32_t id;
    std::vector&amp;lt;Edge*&amp;gt; out_edges;
    void* payload;     // depends on node type
    uint32_t type;
};

struct Edge {
    Node* from;
    Node* to;
    void* payload;     // depends on edge type
    uint32_t type;
}; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Looks flexible. In reality, it is a tiny undocumented database with manual referential integrity.Everyone must remember things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;which Node::type maps to which payload struct&lt;/li&gt;
&lt;li&gt;which Edge::type maps to which payload struct&lt;/li&gt;
&lt;li&gt;whether payload == nullptr is allowed&lt;/li&gt;
&lt;li&gt;who owns node payloads&lt;/li&gt;
&lt;li&gt;who owns edge payloads&lt;/li&gt;
&lt;li&gt;whether deleting a node invalidates edge payloads&lt;/li&gt;
&lt;li&gt;how to serialize pointer graphs without losing meaning&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And when requirements change, the whole thing turns into archaeology.  Now let’s model the same problem like people who know schemas exist.&lt;/p&gt;
&lt;p&gt;Start with identity:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;using NodeId = uint32_t;
using EdgeId = uint32_t; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the core tables:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct NodeRow {
    NodeId id;
};

struct EdgeRow {
    EdgeId id;
    NodeId from;
    NodeId to;
}; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That alone already gives you something huge:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;edges are explicitly relations between nodes&lt;/li&gt;
&lt;li&gt;serialization is trivial&lt;/li&gt;
&lt;li&gt;deletion rules can be formalized&lt;/li&gt;
&lt;li&gt;validation is possible&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now add typed data associated with nodes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct TransformNode {
    NodeId node;
    float x, y, z;
};

struct LabelNode {
    NodeId node;
    std::string text;
};

struct ColorNode {
    NodeId node;
    uint8_t r, g, b, a;
}; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And typed data associated with edges:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct WeightEdge {
    EdgeId edge;
    float weight;
};

struct CapacityEdge {
    EdgeId edge;
    uint32_t max_flow;
};

struct StyleEdge {
    EdgeId edge;
    uint32_t line_style;
}; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the model tells the truth.&lt;/p&gt;
&lt;p&gt;A node may have a transform, or a label, or both, or neither. An edge may have a weight, a capacity, or some other attribute. No void*. No “if type == 3 cast to Foo”. No comment-based schema.&lt;/p&gt;
&lt;p&gt;Need multiplicity rules?&lt;/p&gt;
&lt;p&gt;Make them explicit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;one TransformNode row per node&lt;/li&gt;
&lt;li&gt;zero or one WeightEdge row per edge&lt;/li&gt;
&lt;li&gt;many LabelNode rows if your model allows multilingual labels&lt;/li&gt;
&lt;li&gt;no EdgeRow may reference missing nodes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is database thinking.&lt;/p&gt;
&lt;p&gt;And it buys you real benefits even in plain C++:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;strong typing instead of tribal knowledge&lt;/li&gt;
&lt;li&gt;explicit foreign keys instead of raw pointers&lt;/li&gt;
&lt;li&gt;easy serialization&lt;/li&gt;
&lt;li&gt;easy validation&lt;/li&gt;
&lt;li&gt;better data locality with arrays of records&lt;/li&gt;
&lt;li&gt;queryable structure&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Examples of useful queries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;find all edges whose to node is missing a transform&lt;/li&gt;
&lt;li&gt;find all nodes with outgoing edges but no label&lt;/li&gt;
&lt;li&gt;find all weighted edges crossing between two node groups&lt;/li&gt;
&lt;li&gt;find dangling relations after a delete&lt;/li&gt;
&lt;li&gt;find all data attached to a given node or edge&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Try doing that cleanly in a pointer soup.&lt;/p&gt;
&lt;p&gt;This is why I keep saying:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Many C/C++ codebases are just badly normalized databases with manual&lt;br&gt;
memory management.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The irony is that game developers already stumbled into one good answer:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ECS.&lt;/strong&gt; (or Entity-Component-System)&lt;/p&gt;
&lt;p&gt;Entities are IDs. Components are typed tables. Systems are queries.&lt;/p&gt;
&lt;p&gt;That idea is not limited to games. Graphs, CAD tools, compilers, pipelines, simulation systems, scene graphs, workflow engines — all of them can benefit from the same data-first thinking.&lt;/p&gt;
&lt;p&gt;The real lesson is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Before you design your structs like a C programmer, design your data&lt;br&gt;
like a database engineer. Then make it fast like a systems programmer.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;“Nice theory… but won’t this be slow?”&lt;/p&gt;
&lt;p&gt;That’s the first reaction when you suggest modeling C++ data like a database. Fair question. Also mostly wrong.&lt;/p&gt;
&lt;p&gt;Let’s take the graph model from the previous post and talk about how you actually make it fast.&lt;/p&gt;
&lt;p&gt;First mistake people imagine:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Tables + IDs = like SQL = slow joins”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;No. You are not building a database engine. You are building arrays with structure.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Storage: SoA beats pointer soup&lt;br&gt;
Instead of scattered heap objects:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;Node* -&amp;gt; Edge* -&amp;gt; Node* -&amp;gt; payload*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You store dense arrays:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;std::vector&amp;lt;NodeRow&amp;gt; nodes;
std::vector&amp;lt;EdgeRow&amp;gt; edges;
std::vector&amp;lt;TransformNode&amp;gt; transforms;
std::vector&amp;lt;WeightEdge&amp;gt; weights;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;sequential memory&lt;/li&gt;
&lt;li&gt;predictable access&lt;/li&gt;
&lt;li&gt;prefetch-friendly&lt;/li&gt;
&lt;li&gt;no pointer chasing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This alone often wins before you optimize anything else.&lt;/p&gt;
&lt;p&gt;Aside of pure performance the inspection and debugging of such data organization is way simpler than chasing void* pointer soup.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;IDs are cheaper than pointers (in practice)&lt;br&gt;
A pointer graph:&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;random memory access&lt;/li&gt;
&lt;li&gt;poor cache locality&lt;/li&gt;
&lt;li&gt;hard to batch&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An ID-based graph:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NodeId -&amp;gt; index lookup -&amp;gt; contiguous data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yes, you pay an indirection. But you remove cache misses. On modern CPUs, that’s usually a net win.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;“Joins” are just loops&lt;br&gt;
Example:&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;find all edges with weights where from node has a transform&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;You don’t need SQL.&lt;/p&gt;
&lt;p&gt;You do:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (auto&amp;amp; w : weights) {
    auto&amp;amp; edge = edges[w.edge];
    if (has_transform[edge.from]) {
        // process
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;no virtual dispatch&lt;/li&gt;
&lt;li&gt;no pointer casting&lt;/li&gt;
&lt;li&gt;no branching on “type”&lt;/li&gt;
&lt;li&gt;no cache-miss lottery&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Just tight loops over arrays. Eventually you may even come up with an idea of implementing a language integrated query (LINQ) framework.                                         &lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Build indices where needed&lt;br&gt;
If something is frequent, index it:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;std::vector&amp;lt;std::vector&amp;lt;EdgeId&amp;gt;&amp;gt; edges_from_node;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;std::unordered_map&amp;lt;NodeId, TransformNode&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or even better:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;sorted vectors + binary search&lt;/li&gt;
&lt;li&gt;dense bitsets&lt;/li&gt;
&lt;li&gt;sparse sets&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You choose the structure per access pattern. Not everything needs to be O(1). Everything needs to be &lt;em&gt;predictable&lt;/em&gt;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Optional data without branching hell&lt;br&gt;
Instead of:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;if (node-&amp;gt;payload &amp;amp;&amp;amp; node-&amp;gt;type == TRANSFORM)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;presence in table&lt;/li&gt;
&lt;li&gt;or a bitset mask&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;if (has_transform[node_id]) { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Branch becomes predictable. Data becomes separate. Code becomes honest.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Memory management becomes trivial&lt;br&gt;
Pointer model:&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;who owns what?&lt;/li&gt;
&lt;li&gt;who deletes what?&lt;/li&gt;
&lt;li&gt;what invalidates what?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Table model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;vectors own memory&lt;/li&gt;
&lt;li&gt;IDs stay stable (or use handles/generations)&lt;/li&gt;
&lt;li&gt;bulk free is trivial                                                                                                                                                                                                                             - serialization is dump-and-load, computing diffs is trivial (multiplayer game state synchronization anyone?)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You stop fighting lifetime bugs.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;You can still go “full performance mode”&lt;br&gt;
Nothing stops you from:&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;struct-of-arrays layouts&lt;/li&gt;
&lt;li&gt;SIMD over columns&lt;/li&gt;
&lt;li&gt;chunking (archetypes like ECS)&lt;/li&gt;
&lt;li&gt;custom allocators / arenas&lt;/li&gt;
&lt;li&gt;memory mapping&lt;/li&gt;
&lt;li&gt;compression of columns&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In fact, this model makes those optimizations easier, not harder.&lt;/p&gt;
&lt;p&gt;The uncomfortable truth&lt;br&gt;
Pointer-heavy designs feel “low level”. But they often produce:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;worse cache behavior&lt;/li&gt;
&lt;li&gt;harder reasoning&lt;/li&gt;
&lt;li&gt;fragile invariants&lt;/li&gt;
&lt;li&gt;slower real-world performance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because modern performance is not about pointer flexibility. It’s about &lt;strong&gt;data layout and predictability&lt;/strong&gt;.                                                                                &lt;/p&gt;
&lt;p&gt;The mental shift&lt;br&gt;
Stop thinking:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“What does this object point to?”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Start thinking:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Where does this fact live, and how do I iterate it efficiently?”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Database-style modeling is not about SQL.&lt;/p&gt;
&lt;p&gt;It’s about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;explicit structure&lt;/li&gt;
&lt;li&gt;normalized data&lt;/li&gt;
&lt;li&gt;predictable access&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And that’s exactly what high-performance C++ needs. Next time you reach for *&lt;em&gt;void&lt;/em&gt; + enum** …ask yourself: Am I designing a data structure?&lt;br&gt;
Or quietly building a database without admitting it?&lt;/p&gt;
&lt;p&gt;Database-style modeling is powerful in C++. But it is not always the right tool. This matters, because every good idea becomes harmful when people turn it into a religion.&lt;br&gt;
So when should you not model everything as tables + IDs?&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When behavior matters more than data&lt;br&gt;
Sometimes the central thing is not “what facts exist?”&lt;br&gt;
Sometimes the central thing is:&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;“What operation should this object perform?”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct Shape {
    virtual float area() const = 0;
    virtual void draw(Renderer&amp;amp;) const = 0;
}; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you have a small, stable set of objects with rich behavior, polymorphism may be cleaner than forcing everything into component tables.&lt;/p&gt;
&lt;p&gt;A table model is great for &lt;strong&gt;querying data&lt;/strong&gt;. An object model is often better for &lt;strong&gt;encapsulating behavior&lt;/strong&gt;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When the hierarchy is real and stable&lt;br&gt;
If your domain naturally has a closed hierarchy:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;Circle
Rectangle
Triangle 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then &lt;em&gt;std::variant&lt;/em&gt; can be excellent:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;using Shape = std::variant&amp;lt;Circle, Rectangle, Triangle&amp;gt;; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;strong&gt;void&lt;/strong&gt;*. No fake database. No dynamic allocation required. Just a type-safe tagged union.&lt;br&gt;
This is much better than pretending every tiny hierarchy needs an ID table.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When object identity is not important&lt;br&gt;
Tables shine when identity and relationships matter. But not everything has identity.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A Vec3, Color, Matrix, Rect, Range, or BoundingBox is usually just a value. Do not give every value object an ID. That way lies madness.&lt;/p&gt;
&lt;p&gt;Sometimes the right model is simply:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct Color {
    uint8_t r, g, b, a;
}; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not every struct is secretly a table row.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When the data is tiny and local&lt;br&gt;
If a structure is:&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;small&lt;/li&gt;
&lt;li&gt;short-lived&lt;/li&gt;
&lt;li&gt;private to one algorithm&lt;/li&gt;
&lt;li&gt;not serialized&lt;/li&gt;
&lt;li&gt;not shared across systems&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then simple direct structs are often best.&lt;br&gt;
Example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct Candidate {
    float score;
    uint32_t index;
}; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No schema needed. No registry. No component store.&lt;br&gt;
Just code.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When traversal is the whole point&lt;br&gt;
Some structures are naturally pointer-shaped.&lt;br&gt;
Examples:&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;intrusive linked lists&lt;/li&gt;
&lt;li&gt;trees with parent pointers&lt;/li&gt;
&lt;li&gt;arena-allocated AST nodes&lt;/li&gt;
&lt;li&gt;graph algorithms optimized around adjacency&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A table representation can still work, but it may not be worth the extra ceremony.&lt;br&gt;
If the dominant operation is “walk this exact shape,” direct references can be perfectly reasonable.&lt;br&gt;
The mistake is not using pointers. The mistake is using pointers as an undocumented schema.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When lifetime is simple and ownership is obvious&lt;br&gt;
If ownership is clear:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;std::unique_ptr&amp;lt;Node&amp;gt; root; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and the whole structure dies together, then a pointer graph may be fine.&lt;br&gt;
Especially with arenas.&lt;br&gt;
A compiler AST is a good example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;build it&lt;/li&gt;
&lt;li&gt;analyze it&lt;/li&gt;
&lt;li&gt;maybe transform it&lt;/li&gt;
&lt;li&gt;destroy it all at once&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That can be simple and fast.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When the abstraction boundary is behavior-based&lt;br&gt;
Sometimes the whole point is to hide the implementation.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct StorageBackend {
    virtual void write(std::span&amp;lt;const std::byte&amp;gt;) = 0;
    virtual std::vector&amp;lt;std::byte&amp;gt; read(Key) = 0;
}; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You do not want callers reaching into tables here.&lt;br&gt;
You want an interface.&lt;br&gt;
Data-first design is not an excuse to destroy encapsulation.&lt;/p&gt;
&lt;p&gt;So what is the rule?&lt;br&gt;
Use table/ID/database-style modeling when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;identity matters&lt;/li&gt;
&lt;li&gt;relationships matter&lt;/li&gt;
&lt;li&gt;serialization matters&lt;/li&gt;
&lt;li&gt;validation matters&lt;/li&gt;
&lt;li&gt;many systems touch the same data&lt;br&gt;
the model evolves over time&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use object/value/polymorphic design when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;behavior matters more than relationships&lt;/li&gt;
&lt;li&gt;data is local&lt;/li&gt;
&lt;li&gt;hierarchy is small and stable&lt;/li&gt;
&lt;li&gt;ownership is simple&lt;/li&gt;
&lt;li&gt;encapsulation matters more than introspection&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The real enemy is not OOP. The real enemy is informal contracts.&lt;br&gt;
*&lt;em&gt;void&lt;/em&gt; + enum + comment** is not a design pattern.&lt;br&gt;
It is a schema that gave up.&lt;/p&gt;
&lt;p&gt;Good C++ is not “always objects.” Good C++ is not “always tables.”&lt;br&gt;
Good C++ is choosing the representation that makes illegal states hard, common operations fast, and future maintenance survivable.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30002/write-c-like-its-sql</guid>
<pubDate>Tue, 06 Oct 2026 12:59:20 +0000</pubDate>
</item>
<item>
<title>The Entropy of a Growing Database</title>
<link>https://coderlegion.com/29973/the-entropy-of-a-growing-database</link>
<description>&lt;p&gt;There is a strange moment in every software system.&lt;/p&gt;
&lt;p&gt;At the beginning, the database feels almost too simple.&lt;/p&gt;
&lt;p&gt;There are a few tables. A handful of records. A couple of indexes. The queries are obvious. The relationships make sense. You know where almost everything lives.&lt;/p&gt;
&lt;p&gt;Then the application grows.&lt;/p&gt;
&lt;p&gt;Users arrive. Features multiply. Logs accumulate. Transactions increase. New developers join the project. Old assumptions become new requirements. Temporary tables become permanent. Fields that were once optional become important. Queries written for one thousand records are suddenly running against ten million.&lt;/p&gt;
&lt;p&gt;Nothing necessarily breaks.&lt;/p&gt;
&lt;p&gt;That is what makes it interesting.&lt;/p&gt;
&lt;p&gt;The database simply becomes different.&lt;/p&gt;
&lt;p&gt;It develops history.&lt;/p&gt;
&lt;p&gt;And with history comes something that engineers often experience long before they give it a name:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;entropy.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In physics, entropy is associated with disorder and the number of possible states within a system. In software engineering, we can use the idea as a useful mental model.&lt;/p&gt;
&lt;p&gt;A growing database does not necessarily become &quot;disorganized&quot; simply because it becomes large. Instead, it accumulates more possibilities, more relationships, more edge cases, more historical decisions, and more information about the past.&lt;/p&gt;
&lt;p&gt;The database becomes a record not only of the application, but of the application's evolution.&lt;/p&gt;
&lt;p&gt;Every column tells a story.&lt;/p&gt;
&lt;p&gt;Every index represents a decision.&lt;/p&gt;
&lt;p&gt;Every nullable field contains a possibility.&lt;/p&gt;
&lt;p&gt;Every old record carries historical context.&lt;/p&gt;
&lt;p&gt;Every duplicated value may be evidence of a decision someone once made.&lt;/p&gt;
&lt;p&gt;The database is no longer just storage.&lt;/p&gt;
&lt;p&gt;It becomes an archaeological site.&lt;/p&gt;
&lt;h3 id=&quot;the-first-database-is-usually-beautiful&quot;&gt;The First Database Is Usually Beautiful&lt;/h3&gt;
&lt;p&gt;Imagine creating a new application.&lt;/p&gt;
&lt;p&gt;You might start with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;users
products
orders
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The relationships are straightforward.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;User
  |
  +---- Order
           |
           +---- Product
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is something satisfying about this stage.&lt;/p&gt;
&lt;p&gt;The schema is small enough to fit inside your head.&lt;/p&gt;
&lt;p&gt;You can open the database and understand it immediately.&lt;/p&gt;
&lt;p&gt;A user has an ID.&lt;/p&gt;
&lt;p&gt;An order belongs to a user.&lt;/p&gt;
&lt;p&gt;An order contains products.&lt;/p&gt;
&lt;p&gt;Everything feels intentional.&lt;/p&gt;
&lt;p&gt;This is partly because the system has very little history.&lt;/p&gt;
&lt;p&gt;You are designing the present.&lt;/p&gt;
&lt;p&gt;There are no old migrations to consider.&lt;/p&gt;
&lt;p&gt;No legacy records.&lt;/p&gt;
&lt;p&gt;No abandoned features.&lt;/p&gt;
&lt;p&gt;No compatibility requirements.&lt;/p&gt;
&lt;p&gt;No strange values inserted by an earlier version of the application.&lt;/p&gt;
&lt;p&gt;The database represents a relatively clean model of the current idea.&lt;/p&gt;
&lt;p&gt;But software rarely stays in its first form.&lt;/p&gt;
&lt;p&gt;The application evolves.&lt;/p&gt;
&lt;p&gt;And the database evolves with it.&lt;/p&gt;
&lt;h3 id=&quot;every-feature-leaves-a-trace&quot;&gt;Every Feature Leaves a Trace&lt;/h3&gt;
&lt;p&gt;Suppose the application introduces subscriptions.&lt;/p&gt;
&lt;p&gt;Now we add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;subscriptions
subscription_plans
payments
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Later, someone adds discounts.&lt;/p&gt;
&lt;p&gt;Then:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;discounts
coupon_codes
order_discounts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then refunds.&lt;/p&gt;
&lt;p&gt;Then invoices.&lt;/p&gt;
&lt;p&gt;Then payment attempts.&lt;/p&gt;
&lt;p&gt;Then notifications.&lt;/p&gt;
&lt;p&gt;Then audit records.&lt;/p&gt;
&lt;p&gt;Then reporting tables.&lt;/p&gt;
&lt;p&gt;None of these additions are inherently bad.&lt;/p&gt;
&lt;p&gt;In fact, they may represent good engineering.&lt;/p&gt;
&lt;p&gt;The important observation is that every feature expands the state space of the system.&lt;/p&gt;
&lt;p&gt;The database now has more possible configurations.&lt;/p&gt;
&lt;p&gt;An order can be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;created
paid
cancelled
refunded
partially_refunded
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A subscription can be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;trial
active
paused
expired
cancelled
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A payment can be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pending
successful
failed
reversed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The system has become more expressive.&lt;/p&gt;
&lt;p&gt;But it has also become more complex.&lt;/p&gt;
&lt;p&gt;This is one way to think about database entropy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Growth increases the number of relationships and states that the system must preserve correctly.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;rows-are-history&quot;&gt;Rows Are History&lt;/h3&gt;
&lt;p&gt;A database row is not merely a piece of information.&lt;/p&gt;
&lt;p&gt;It is a snapshot of a decision made at a particular point in time.&lt;/p&gt;
&lt;p&gt;Consider a user record:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;id: 42
name: Derek
email: *Emails are not allowed*
status: active
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Six months later, the application changes its user model.&lt;/p&gt;
&lt;p&gt;Maybe users can now have multiple email addresses.&lt;/p&gt;
&lt;p&gt;Perhaps the system introduces organizations.&lt;/p&gt;
&lt;p&gt;Maybe names can be changed.&lt;/p&gt;
&lt;p&gt;Maybe accounts can be suspended.&lt;/p&gt;
&lt;p&gt;The original row still exists.&lt;/p&gt;
&lt;p&gt;The schema has changed, but the row carries the historical consequences of the old schema.&lt;/p&gt;
&lt;p&gt;This is one reason migrations are more than technical operations.&lt;/p&gt;
&lt;p&gt;A migration is often a negotiation between the present and the past.&lt;/p&gt;
&lt;p&gt;You are asking an existing collection of data to live inside a new interpretation.&lt;/p&gt;
&lt;p&gt;That is surprisingly profound.&lt;/p&gt;
&lt;p&gt;The application changes quickly.&lt;/p&gt;
&lt;p&gt;The data remembers slowly.&lt;/p&gt;
&lt;h3 id=&quot;the-database-remembers-everything&quot;&gt;The Database Remembers Everything&lt;/h3&gt;
&lt;p&gt;Application code can be replaced.&lt;/p&gt;
&lt;p&gt;A framework can be changed.&lt;/p&gt;
&lt;p&gt;A frontend can be redesigned.&lt;/p&gt;
&lt;p&gt;An API can be rewritten.&lt;/p&gt;
&lt;p&gt;But the database often survives all of them.&lt;/p&gt;
&lt;p&gt;This makes the database one of the longest-lived parts of many systems.&lt;/p&gt;
&lt;p&gt;Imagine an application that has existed for ten years.&lt;/p&gt;
&lt;p&gt;The current codebase may only reflect the latest two years of architectural decisions.&lt;/p&gt;
&lt;p&gt;The database may reflect all ten.&lt;/p&gt;
&lt;p&gt;It remembers the old customer model.&lt;/p&gt;
&lt;p&gt;The old pricing model.&lt;/p&gt;
&lt;p&gt;The old authentication system.&lt;/p&gt;
&lt;p&gt;The old reporting system.&lt;/p&gt;
&lt;p&gt;The old product structure.&lt;/p&gt;
&lt;p&gt;The database becomes a kind of institutional memory.&lt;/p&gt;
&lt;p&gt;Sometimes this memory is useful.&lt;/p&gt;
&lt;p&gt;Sometimes it is inconvenient.&lt;/p&gt;
&lt;p&gt;Often, it is both.&lt;/p&gt;
&lt;h3 id=&quot;null-is-a-possibility&quot;&gt;Null Is a Possibility&lt;/h3&gt;
&lt;p&gt;One of the most interesting forms of database entropy is the nullable field.&lt;/p&gt;
&lt;p&gt;Consider:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;phone_number VARCHAR(30) NULL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At first, this seems simple.&lt;/p&gt;
&lt;p&gt;A user may or may not have a phone number.&lt;/p&gt;
&lt;p&gt;But as the application grows, &lt;code&gt;NULL&lt;/code&gt; can acquire different meanings.&lt;/p&gt;
&lt;p&gt;Does it mean:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the user never provided a phone number?&lt;/li&gt;
&lt;li&gt;the user declined to provide one?&lt;/li&gt;
&lt;li&gt;the number has not been verified?&lt;/li&gt;
&lt;li&gt;the information was imported from an older system?&lt;/li&gt;
&lt;li&gt;the number was deleted?&lt;/li&gt;
&lt;li&gt;the application does not know whether one exists?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A single database value can represent multiple conceptual states.&lt;/p&gt;
&lt;p&gt;This is why mature systems sometimes require more explicit modeling.&lt;/p&gt;
&lt;p&gt;Instead of:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;phone_number = NULL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;you may eventually have:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;phone_number
phone_verified
phone_verified_at
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the system can express more.&lt;/p&gt;
&lt;p&gt;But expressing more also means managing more states.&lt;/p&gt;
&lt;p&gt;The database becomes more precise.&lt;/p&gt;
&lt;p&gt;And precision creates responsibility.&lt;/p&gt;
&lt;h3 id=&quot;the-entropy-of-meaning&quot;&gt;The Entropy of Meaning&lt;/h3&gt;
&lt;p&gt;Database complexity is not only about the number of tables.&lt;/p&gt;
&lt;p&gt;It is also about meaning.&lt;/p&gt;
&lt;p&gt;Imagine a column called:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What does it mean?&lt;/p&gt;
&lt;p&gt;At first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;active
inactive
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Later:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;active
inactive
pending
suspended
archived
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then different parts of the application begin interpreting those values differently.&lt;/p&gt;
&lt;p&gt;The frontend uses &lt;code&gt;inactive&lt;/code&gt; to mean &quot;not currently visible.&quot;&lt;/p&gt;
&lt;p&gt;The API uses it to mean &quot;account disabled.&quot;&lt;/p&gt;
&lt;p&gt;The reporting system uses it to mean &quot;customer has not purchased recently.&quot;&lt;/p&gt;
&lt;p&gt;The same word has slowly accumulated multiple interpretations.&lt;/p&gt;
&lt;p&gt;This is a different kind of entropy.&lt;/p&gt;
&lt;p&gt;The structure may still look clean.&lt;/p&gt;
&lt;p&gt;The meaning is what has become complicated.&lt;/p&gt;
&lt;p&gt;Good database design therefore involves not only asking:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;What data do we store?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;but also:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;What exactly does this data mean?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;indexes-are-memory-with-a-cost&quot;&gt;Indexes Are Memory With a Cost&lt;/h3&gt;
&lt;p&gt;As databases grow, indexes usually grow with them.&lt;/p&gt;
&lt;p&gt;An index is an elegant idea.&lt;/p&gt;
&lt;p&gt;Instead of searching every row, the database maintains additional structure that helps locate information efficiently.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX idx_users_email
ON users(email);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Suddenly, finding a user by email becomes much easier.&lt;/p&gt;
&lt;p&gt;But indexes are not free.&lt;/p&gt;
&lt;p&gt;They consume storage.&lt;/p&gt;
&lt;p&gt;They require maintenance.&lt;/p&gt;
&lt;p&gt;Writes must update them.&lt;/p&gt;
&lt;p&gt;Updates can become more expensive.&lt;/p&gt;
&lt;p&gt;And eventually a large system may contain indexes created for queries that are no longer important.&lt;/p&gt;
&lt;p&gt;This is another form of accumulated history.&lt;/p&gt;
&lt;p&gt;An index may represent a query that mattered three years ago.&lt;/p&gt;
&lt;p&gt;The query disappeared.&lt;/p&gt;
&lt;p&gt;The index remained.&lt;/p&gt;
&lt;p&gt;The database remembers a need that the application no longer has.&lt;/p&gt;
&lt;p&gt;This does not mean indexes are bad.&lt;/p&gt;
&lt;p&gt;It means that a mature database deserves periodic observation.&lt;/p&gt;
&lt;p&gt;Sometimes optimization is not about adding another index.&lt;/p&gt;
&lt;p&gt;Sometimes it is about asking whether an old one still belongs there.&lt;/p&gt;
&lt;h3 id=&quot;data-creates-gravity&quot;&gt;Data Creates Gravity&lt;/h3&gt;
&lt;p&gt;There is a point in a system's life where data starts influencing architecture.&lt;/p&gt;
&lt;p&gt;At the beginning, architecture determines the data model.&lt;/p&gt;
&lt;p&gt;Later, the data model starts influencing architecture.&lt;/p&gt;
&lt;p&gt;Suppose a table has:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;500 rows
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can make significant changes easily.&lt;/p&gt;
&lt;p&gt;Now imagine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;500 million rows
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same change has a completely different character.&lt;/p&gt;
&lt;p&gt;A schema migration that takes seconds on a development machine may require careful planning in production.&lt;/p&gt;
&lt;p&gt;A query that feels instantaneous on sample data may behave differently at scale.&lt;/p&gt;
&lt;p&gt;A table that once felt small becomes an architectural component.&lt;/p&gt;
&lt;p&gt;This is data gravity.&lt;/p&gt;
&lt;p&gt;The more data accumulates, the more strongly it influences the decisions around it.&lt;/p&gt;
&lt;p&gt;The database becomes difficult to move because the data has weight.&lt;/p&gt;
&lt;p&gt;Not physical weight.&lt;/p&gt;
&lt;p&gt;Architectural weight.&lt;/p&gt;
&lt;h3 id=&quot;time-is-another-dimension&quot;&gt;Time Is Another Dimension&lt;/h3&gt;
&lt;p&gt;A database does not merely contain objects.&lt;/p&gt;
&lt;p&gt;It contains time.&lt;/p&gt;
&lt;p&gt;Consider an order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;created_at
updated_at
paid_at
shipped_at
delivered_at
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The record now describes a sequence.&lt;/p&gt;
&lt;p&gt;The database becomes a timeline.&lt;/p&gt;
&lt;p&gt;And once a system starts storing historical information, current state is no longer enough.&lt;/p&gt;
&lt;p&gt;Suppose an account is currently:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;active
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That tells us what it is now.&lt;/p&gt;
&lt;p&gt;It does not tell us whether it was suspended last year.&lt;/p&gt;
&lt;p&gt;History requires additional structure.&lt;/p&gt;
&lt;p&gt;This is where audit logs, event tables, history tables, and event-driven designs become valuable.&lt;/p&gt;
&lt;p&gt;The system begins storing not only:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;What is true?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;but:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;What became true, and when?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The second question creates much more information.&lt;/p&gt;
&lt;p&gt;And much more information creates more complexity.&lt;/p&gt;
&lt;h3 id=&quot;entropy-is-not-the-enemy&quot;&gt;Entropy Is Not the Enemy&lt;/h3&gt;
&lt;p&gt;It would be tempting to conclude that database entropy is something engineers should eliminate.&lt;/p&gt;
&lt;p&gt;But that would miss the point.&lt;/p&gt;
&lt;p&gt;Some complexity is necessary.&lt;/p&gt;
&lt;p&gt;A real system needs to represent real situations.&lt;/p&gt;
&lt;p&gt;Customers change their information.&lt;/p&gt;
&lt;p&gt;Orders change status.&lt;/p&gt;
&lt;p&gt;Payments fail.&lt;/p&gt;
&lt;p&gt;Products are discontinued.&lt;/p&gt;
&lt;p&gt;Businesses change rules.&lt;/p&gt;
&lt;p&gt;Users make mistakes.&lt;/p&gt;
&lt;p&gt;Systems recover from failures.&lt;/p&gt;
&lt;p&gt;History accumulates.&lt;/p&gt;
&lt;p&gt;A database that perfectly represents a complex world will naturally contain complexity.&lt;/p&gt;
&lt;p&gt;The goal is therefore not to create a database with zero entropy.&lt;/p&gt;
&lt;p&gt;The goal is to keep complexity &lt;strong&gt;understood&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That distinction matters.&lt;/p&gt;
&lt;p&gt;A complex schema can be healthy.&lt;/p&gt;
&lt;p&gt;An apparently simple schema can be dangerous if its meanings are unclear.&lt;/p&gt;
&lt;h3 id=&quot;the-importance-of-constraints&quot;&gt;The Importance of Constraints&lt;/h3&gt;
&lt;p&gt;One of the best ways to control database entropy is to make important rules explicit.&lt;/p&gt;
&lt;p&gt;Consider:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;email VARCHAR(255)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;versus:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;email VARCHAR(255) NOT NULL UNIQUE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second schema communicates more.&lt;/p&gt;
&lt;p&gt;It tells the database that every row must have an email and that duplicate emails are not allowed.&lt;/p&gt;
&lt;p&gt;Constraints turn assumptions into rules.&lt;/p&gt;
&lt;p&gt;Foreign keys do the same.&lt;/p&gt;
&lt;p&gt;Check constraints do the same.&lt;/p&gt;
&lt;p&gt;Unique indexes do the same.&lt;/p&gt;
&lt;p&gt;A strong database does not rely entirely on application code to preserve its meaning.&lt;/p&gt;
&lt;p&gt;It allows the database to protect important invariants.&lt;/p&gt;
&lt;p&gt;In a growing system, this becomes increasingly valuable.&lt;/p&gt;
&lt;p&gt;The more the system grows, the more places there are for assumptions to be violated.&lt;/p&gt;
&lt;p&gt;Explicit rules reduce ambiguity.&lt;/p&gt;
&lt;h3 id=&quot;good-schemas-evolve&quot;&gt;Good Schemas Evolve&lt;/h3&gt;
&lt;p&gt;A common misconception is that good database design means creating a schema that never needs to change.&lt;/p&gt;
&lt;p&gt;Real systems rarely work that way.&lt;/p&gt;
&lt;p&gt;Good databases evolve.&lt;/p&gt;
&lt;p&gt;The important question is whether the evolution is understandable.&lt;/p&gt;
&lt;p&gt;A healthy schema might have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;clear naming;&lt;/li&gt;
&lt;li&gt;explicit constraints;&lt;/li&gt;
&lt;li&gt;documented relationships;&lt;/li&gt;
&lt;li&gt;sensible indexes;&lt;/li&gt;
&lt;li&gt;predictable migrations;&lt;/li&gt;
&lt;li&gt;well-defined states;&lt;/li&gt;
&lt;li&gt;historical information where necessary;&lt;/li&gt;
&lt;li&gt;careful handling of deprecated fields.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The schema does not need to remain frozen.&lt;/p&gt;
&lt;p&gt;It needs to remain legible.&lt;/p&gt;
&lt;p&gt;Think about a city.&lt;/p&gt;
&lt;p&gt;A city that grows does not remain identical to the village that existed before it.&lt;/p&gt;
&lt;p&gt;Roads are added.&lt;/p&gt;
&lt;p&gt;Buildings change.&lt;/p&gt;
&lt;p&gt;Old structures are preserved.&lt;/p&gt;
&lt;p&gt;New districts appear.&lt;/p&gt;
&lt;p&gt;Infrastructure is upgraded.&lt;/p&gt;
&lt;p&gt;The city becomes more complex.&lt;/p&gt;
&lt;p&gt;But it can still be navigable.&lt;/p&gt;
&lt;p&gt;A mature database should work similarly.&lt;/p&gt;
&lt;h3 id=&quot;cleaning-is-part-of-engineering&quot;&gt;Cleaning Is Part of Engineering&lt;/h3&gt;
&lt;p&gt;Database maintenance is often associated with backups, indexes, and performance.&lt;/p&gt;
&lt;p&gt;But cleanup deserves equal attention.&lt;/p&gt;
&lt;p&gt;Unused columns can be investigated.&lt;/p&gt;
&lt;p&gt;Deprecated records can be archived when appropriate.&lt;/p&gt;
&lt;p&gt;Old indexes can be evaluated.&lt;/p&gt;
&lt;p&gt;Duplicate data can be understood.&lt;/p&gt;
&lt;p&gt;Historical tables can be separated from operational data.&lt;/p&gt;
&lt;p&gt;Large datasets can be partitioned where useful.&lt;/p&gt;
&lt;p&gt;The goal is not to delete information simply because it is old.&lt;/p&gt;
&lt;p&gt;The goal is to understand what information the system has accumulated and why it exists.&lt;/p&gt;
&lt;p&gt;Sometimes the correct answer is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Keep it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Sometimes:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Archive it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Sometimes:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Transform it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Sometimes:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Remove it safely.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Good cleanup is not about making the database look small.&lt;/p&gt;
&lt;p&gt;It is about making its structure understandable.&lt;/p&gt;
&lt;h3 id=&quot;the-database-as-a-map-of-the-product&quot;&gt;The Database as a Map of the Product&lt;/h3&gt;
&lt;p&gt;One of the most interesting things about a mature database is that you can often reconstruct the history of the product by studying its schema.&lt;/p&gt;
&lt;p&gt;You might find:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;users
orders
subscriptions
legacy_orders
old_payments
payment_attempts
archived_products
audit_events
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Even without reading the application code, these names tell a story.&lt;/p&gt;
&lt;p&gt;The product started with users.&lt;/p&gt;
&lt;p&gt;Then orders appeared.&lt;/p&gt;
&lt;p&gt;Then subscriptions.&lt;/p&gt;
&lt;p&gt;Payments became more sophisticated.&lt;/p&gt;
&lt;p&gt;Older structures were replaced.&lt;/p&gt;
&lt;p&gt;Historical information became important.&lt;/p&gt;
&lt;p&gt;The database became a map of decisions.&lt;/p&gt;
&lt;p&gt;This is why experienced developers sometimes develop a particular intuition when looking at schemas.&lt;/p&gt;
&lt;p&gt;They are not only seeing tables.&lt;/p&gt;
&lt;p&gt;They are seeing history.&lt;/p&gt;
&lt;h3 id=&quot;entropy-and-developer-experience&quot;&gt;Entropy and Developer Experience&lt;/h3&gt;
&lt;p&gt;There is another interesting consequence.&lt;/p&gt;
&lt;p&gt;As database entropy increases, the importance of developer experience increases too.&lt;/p&gt;
&lt;p&gt;A new developer joining a young project might understand the database in an afternoon.&lt;/p&gt;
&lt;p&gt;A new developer joining a mature system may need weeks.&lt;/p&gt;
&lt;p&gt;Not because they are less capable.&lt;/p&gt;
&lt;p&gt;Because the system contains more history.&lt;/p&gt;
&lt;p&gt;Documentation therefore becomes increasingly valuable as systems mature.&lt;/p&gt;
&lt;p&gt;Schema diagrams help.&lt;/p&gt;
&lt;p&gt;Migration histories help.&lt;/p&gt;
&lt;p&gt;Naming conventions help.&lt;/p&gt;
&lt;p&gt;Comments help.&lt;/p&gt;
&lt;p&gt;Architecture documents help.&lt;/p&gt;
&lt;p&gt;Good local development environments help.&lt;/p&gt;
&lt;p&gt;The more complex the system becomes, the more valuable the map becomes.&lt;/p&gt;
&lt;h3 id=&quot;the-quiet-intelligence-of-a-database&quot;&gt;The Quiet Intelligence of a Database&lt;/h3&gt;
&lt;p&gt;A database does something remarkable.&lt;/p&gt;
&lt;p&gt;It turns the changing world into structure.&lt;/p&gt;
&lt;p&gt;People make decisions.&lt;/p&gt;
&lt;p&gt;Applications transform those decisions.&lt;/p&gt;
&lt;p&gt;Databases preserve them.&lt;/p&gt;
&lt;p&gt;Over time, millions of small events become rows, relationships, timestamps, indexes, constraints, and histories.&lt;/p&gt;
&lt;p&gt;What began as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;users
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;can eventually become a representation of an entire ecosystem.&lt;/p&gt;
&lt;p&gt;Customers.&lt;/p&gt;
&lt;p&gt;Products.&lt;/p&gt;
&lt;p&gt;Payments.&lt;/p&gt;
&lt;p&gt;Messages.&lt;/p&gt;
&lt;p&gt;Events.&lt;/p&gt;
&lt;p&gt;Preferences.&lt;/p&gt;
&lt;p&gt;Transactions.&lt;/p&gt;
&lt;p&gt;Relationships.&lt;/p&gt;
&lt;p&gt;History.&lt;/p&gt;
&lt;p&gt;The database becomes a compressed model of reality.&lt;/p&gt;
&lt;p&gt;Not reality itself.&lt;/p&gt;
&lt;p&gt;But a carefully structured approximation of the parts of reality that the software needs to remember.&lt;/p&gt;
&lt;p&gt;That is why database engineering is so interesting.&lt;/p&gt;
&lt;p&gt;You are not merely storing information.&lt;/p&gt;
&lt;p&gt;You are deciding what the system is allowed to remember.&lt;/p&gt;
&lt;h3 id=&quot;managing-entropy-instead-of-fighting-it&quot;&gt;Managing Entropy Instead of Fighting It&lt;/h3&gt;
&lt;p&gt;The most useful mindset is therefore not:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;How do I stop the database from becoming complicated?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A better question is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;How do I make increasing complexity remain understandable?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That leads to practical habits.&lt;/p&gt;
&lt;p&gt;Name things clearly.&lt;/p&gt;
&lt;p&gt;Model important states explicitly.&lt;/p&gt;
&lt;p&gt;Use constraints.&lt;/p&gt;
&lt;p&gt;Review indexes.&lt;/p&gt;
&lt;p&gt;Document unusual decisions.&lt;/p&gt;
&lt;p&gt;Treat migrations carefully.&lt;/p&gt;
&lt;p&gt;Understand historical data.&lt;/p&gt;
&lt;p&gt;Separate operational and analytical workloads when appropriate.&lt;/p&gt;
&lt;p&gt;Archive deliberately.&lt;/p&gt;
&lt;p&gt;Monitor query behavior.&lt;/p&gt;
&lt;p&gt;Measure before optimizing.&lt;/p&gt;
&lt;p&gt;And most importantly, remember that every database is evolving.&lt;/p&gt;
&lt;p&gt;There is no final schema.&lt;/p&gt;
&lt;p&gt;There is only the current representation of the system.&lt;/p&gt;
&lt;p&gt;Tomorrow may require another table.&lt;/p&gt;
&lt;p&gt;Another relationship.&lt;/p&gt;
&lt;p&gt;Another index.&lt;/p&gt;
&lt;p&gt;Another migration.&lt;/p&gt;
&lt;p&gt;Another idea.&lt;/p&gt;
&lt;p&gt;That is normal.&lt;/p&gt;
&lt;h3 id=&quot;the-database-has-a-memory-of-its-own&quot;&gt;The Database Has a Memory of Its Own&lt;/h3&gt;
&lt;p&gt;Eventually, a growing database becomes something more than storage.&lt;/p&gt;
&lt;p&gt;It becomes memory.&lt;/p&gt;
&lt;p&gt;It remembers what the system looked like.&lt;/p&gt;
&lt;p&gt;It remembers what users did.&lt;/p&gt;
&lt;p&gt;It remembers transactions.&lt;/p&gt;
&lt;p&gt;It remembers changes.&lt;/p&gt;
&lt;p&gt;It remembers relationships.&lt;/p&gt;
&lt;p&gt;It remembers decisions that may no longer exist in the application code.&lt;/p&gt;
&lt;p&gt;And this memory creates complexity.&lt;/p&gt;
&lt;p&gt;But complexity is not necessarily failure.&lt;/p&gt;
&lt;p&gt;Sometimes complexity is simply evidence that something has lived long enough to accumulate history.&lt;/p&gt;
&lt;p&gt;A small database is like an empty notebook.&lt;/p&gt;
&lt;p&gt;A mature database is like a library.&lt;/p&gt;
&lt;p&gt;The challenge is not preventing the library from gaining books.&lt;/p&gt;
&lt;p&gt;The challenge is making sure people can still find what they need.&lt;/p&gt;
&lt;p&gt;That is the real engineering problem behind database entropy.&lt;/p&gt;
&lt;p&gt;Not stopping growth.&lt;/p&gt;
&lt;p&gt;Not fearing complexity.&lt;/p&gt;
&lt;p&gt;Not endlessly simplifying everything.&lt;/p&gt;
&lt;p&gt;But designing systems where complexity can grow without becoming invisible.&lt;/p&gt;
&lt;p&gt;Because every growing database eventually tells a story.&lt;/p&gt;
&lt;p&gt;The tables are its chapters.&lt;/p&gt;
&lt;p&gt;The rows are its sentences.&lt;/p&gt;
&lt;p&gt;The indexes are its shortcuts.&lt;/p&gt;
&lt;p&gt;The constraints are its rules.&lt;/p&gt;
&lt;p&gt;The migrations are its historical revisions.&lt;/p&gt;
&lt;p&gt;And the schema is the architecture that holds the entire story together.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A database does not become interesting when it becomes large.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It becomes interesting when it becomes old enough to remember.&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29973/the-entropy-of-a-growing-database</guid>
<pubDate>Tue, 06 Oct 2026 10:40:36 +0000</pubDate>
</item>
<item>
<title>I Didn’t Want AI to Decide Fraud. I Wanted It to Investigate It.</title>
<link>https://coderlegion.com/29969/i-didnt-want-ai-to-decide-fraud-i-wanted-it-to-investigate-it</link>
<description>&lt;p&gt;How a painful backend, broken assumptions, graph debugging, evidence failures, and one very long final day became CaseVera — an evidence-first fraud investigation agent built on TigerGraph.&lt;/p&gt;
&lt;p&gt;A fraud model can tell you that a transaction looks suspicious.&lt;/p&gt;
&lt;p&gt;But would you block someone’s card because a model says it is 91.8% suspicious?&lt;/p&gt;
&lt;p&gt;What if that customer has made hundreds of nearly identical purchases before?&lt;/p&gt;
&lt;p&gt;What if the device is already familiar?&lt;/p&gt;
&lt;p&gt;What if the region and product are completely normal?&lt;/p&gt;
&lt;p&gt;What if a historical fraud case looks similar but has no direct connection to the current transaction?&lt;/p&gt;
&lt;p&gt;And what if an AI generates a convincing explanation containing a piece of evidence that never actually existed?&lt;/p&gt;
&lt;p&gt;That was the problem I kept running into while building my project for TigerGraph × Hacker House Goa 2026.&lt;/p&gt;
&lt;p&gt;I initially thought the challenge was to build a better fraud detector.&lt;/p&gt;
&lt;p&gt;It wasn’t.&lt;/p&gt;
&lt;p&gt;The harder problem was building a system I would actually trust to investigate fraud.&lt;/p&gt;
&lt;p&gt;That became CaseVera.&lt;/p&gt;
&lt;p&gt;Investigate the case. Not just the score.&lt;/p&gt;
&lt;p&gt;The problem with stopping at a fraud score&lt;/p&gt;
&lt;p&gt;Most fraud pipelines eventually produce some variation of:&lt;/p&gt;
&lt;p&gt;transaction → features → risk score → action&lt;/p&gt;
&lt;p&gt;Risk scoring is extremely useful. But a score answers only one question:&lt;/p&gt;
&lt;p&gt;How suspicious does this transaction look?&lt;/p&gt;
&lt;p&gt;An investigator needs far more.&lt;/p&gt;
&lt;p&gt;They need to know:&lt;/p&gt;
&lt;p&gt;What actually happened?&lt;br&gt;
What evidence supports fraud?&lt;br&gt;
What evidence contradicts that conclusion?&lt;br&gt;
What customers, cards, devices and transactions are connected?&lt;br&gt;
Has something similar happened before?&lt;br&gt;
Which policy applies?&lt;br&gt;
Do we have enough evidence to act?&lt;br&gt;
Should we ask for additional verification?&lt;br&gt;
Who is allowed to approve the action?&lt;br&gt;
Can every claim in the final decision be traced back to its source?&lt;/p&gt;
&lt;p&gt;That is the gap CaseVera tries to address.&lt;/p&gt;
&lt;p&gt;Instead of treating the risk model as the final answer, the risk score becomes the reason to begin an investigation.&lt;/p&gt;
&lt;p&gt;What CaseVera does&lt;/p&gt;
&lt;p&gt;CaseVera is a policy-constrained fraud investigation agent built around TigerGraph.&lt;/p&gt;
&lt;p&gt;Its investigation loop is roughly:&lt;/p&gt;
&lt;p&gt;Trigger → investigate the graph → retrieve context → assess evidence → handle uncertainty → request evidence if needed → apply policy → recommend the next best action → stop → persist case memory&lt;/p&gt;
&lt;p&gt;The system combines:&lt;/p&gt;
&lt;p&gt;TigerGraph + GSQL for structural relationships and graph evidence.&lt;/p&gt;
&lt;p&gt;TigerGraph MCP as the graph-tool transport used by the runtime.&lt;/p&gt;
&lt;p&gt;GraphRAG for retrieving relevant historical cases and policy/regulatory context.&lt;/p&gt;
&lt;p&gt;A statistical fraud model as a suspicion prior.&lt;/p&gt;
&lt;p&gt;A contradiction-aware evidence engine that separates:&lt;/p&gt;
&lt;p&gt;fraud-supporting evidence,&lt;br&gt;
legitimacy evidence,&lt;br&gt;
ambiguous signals.&lt;/p&gt;
&lt;p&gt;A deterministic policy layer for:&lt;/p&gt;
&lt;p&gt;next-best actions,&lt;br&gt;
approval routes,&lt;br&gt;
SAR decisions,&lt;br&gt;
stopping conditions.&lt;/p&gt;
&lt;p&gt;And finally, a persistent case-memory layer that writes the completed investigation back into TigerGraph.&lt;/p&gt;
&lt;p&gt;The decision that shaped the whole architecture&lt;/p&gt;
&lt;p&gt;One decision may seem strange for an AI-agent project in 2026:&lt;/p&gt;
&lt;p&gt;I deliberately kept an LLM out of the fraud decision loop.&lt;/p&gt;
&lt;p&gt;I could have passed the transaction, graph context, previous cases and policies into Gemini or another general-purpose LLM and asked:&lt;/p&gt;
&lt;p&gt;“Is this fraud? Explain why.”&lt;/p&gt;
&lt;p&gt;It would probably return a very convincing answer.&lt;/p&gt;
&lt;p&gt;That is exactly what worried me.&lt;/p&gt;
&lt;p&gt;Fraud investigation is not a place where a convincing explanation is enough.&lt;/p&gt;
&lt;p&gt;A generative model could accidentally:&lt;/p&gt;
&lt;p&gt;mix evidence belonging to different entities,&lt;br&gt;
interpret precedent as proof,&lt;br&gt;
transform an assumption into an observation,&lt;br&gt;
invent a relationship not present in the graph,&lt;br&gt;
overstate certainty,&lt;br&gt;
or recommend an action unsupported by policy.&lt;/p&gt;
&lt;p&gt;In a casual chatbot, a hallucination may just be annoying.&lt;/p&gt;
&lt;p&gt;In a financial investigation, it can contaminate the decision record.&lt;/p&gt;
&lt;p&gt;So I split responsibility differently.&lt;/p&gt;
&lt;p&gt;The model contributes suspicion.&lt;/p&gt;
&lt;p&gt;TigerGraph contributes relationships and facts.&lt;/p&gt;
&lt;p&gt;GraphRAG contributes relevant historical and policy context.&lt;/p&gt;
&lt;p&gt;The evidence engine determines what the current facts actually support.&lt;/p&gt;
&lt;p&gt;The policy engine determines which actions are allowed and who must approve them.&lt;/p&gt;
&lt;p&gt;The LLM does not get to change the verdict, evidence, exposure, approval route or SAR decision.&lt;/p&gt;
&lt;p&gt;That does not mean I think LLMs have no place in CaseVera.&lt;/p&gt;
&lt;p&gt;Quite the opposite.&lt;/p&gt;
&lt;p&gt;My next version would use them heavily at the boundaries:&lt;/p&gt;
&lt;p&gt;turning free-form analyst questions into structured investigation requests,&lt;br&gt;
letting analysts converse with a completed case,&lt;br&gt;
generating concise summaries from already-verified evidence,&lt;br&gt;
converting structured findings into more natural explanations,&lt;br&gt;
improving analyst productivity.&lt;/p&gt;
&lt;p&gt;But the underlying principle would remain:&lt;/p&gt;
&lt;p&gt;Generation should improve the interface. It should never manufacture the evidence.&lt;/p&gt;
&lt;p&gt;The case that validated the idea&lt;/p&gt;
&lt;p&gt;One benchmark case became the best demonstration of why I built the architecture this way.&lt;/p&gt;
&lt;p&gt;HHG-007.&lt;/p&gt;
&lt;p&gt;The model suspicion was around 91.8%.&lt;/p&gt;
&lt;p&gt;If the model score were allowed to dominate the decision, blocking the card would look completely reasonable.&lt;/p&gt;
&lt;p&gt;CaseVera investigated further.&lt;/p&gt;
&lt;p&gt;It found:&lt;/p&gt;
&lt;p&gt;Fraud-supporting signals: 0&lt;/p&gt;
&lt;p&gt;Legitimacy signals: 5&lt;/p&gt;
&lt;p&gt;The amount was normal.&lt;/p&gt;
&lt;p&gt;The region was normal.&lt;/p&gt;
&lt;p&gt;The product behavior was normal.&lt;/p&gt;
&lt;p&gt;And the customer had hundreds of previous charges at very similar amounts.&lt;/p&gt;
&lt;p&gt;CaseVera did not declare the transaction legitimate either.&lt;/p&gt;
&lt;p&gt;It stayed uncertain.&lt;/p&gt;
&lt;p&gt;Its next-best action was:&lt;/p&gt;
&lt;p&gt;Verify first.&lt;/p&gt;
&lt;p&gt;Only if a simulated customer response later denied the transaction did a conditional block appear.&lt;/p&gt;
&lt;p&gt;That case gave me one of the biggest lessons from this project:&lt;/p&gt;
&lt;p&gt;Restraint is also intelligence.&lt;/p&gt;
&lt;p&gt;A good fraud system should know how to act.&lt;/p&gt;
&lt;p&gt;A good investigation system should also know when not to.&lt;/p&gt;
&lt;p&gt;Why TigerGraph mattered&lt;/p&gt;
&lt;p&gt;Some fraud patterns simply do not exist inside one transaction row.&lt;/p&gt;
&lt;p&gt;Consider HHG-014.&lt;/p&gt;
&lt;p&gt;If you inspect only the customer’s transaction, it does not tell the whole story.&lt;/p&gt;
&lt;p&gt;Once TigerGraph follows the associated device fingerprint through the network, something much more interesting appears:&lt;/p&gt;
&lt;p&gt;one device fingerprint → 24 customers → four connected cards&lt;/p&gt;
&lt;p&gt;That structure becomes visible only after traversing relationships across accounts.&lt;/p&gt;
&lt;p&gt;This became another principle behind CaseVera:&lt;/p&gt;
&lt;p&gt;Fraud rarely lives in one row. It lives in relationships.&lt;/p&gt;
&lt;p&gt;The graph contains customers, transactions, cards, device profiles, historical closed cases, investigation cases, evidence and document chunks.&lt;/p&gt;
&lt;p&gt;GSQL queries retrieve things such as:&lt;/p&gt;
&lt;p&gt;shared-device structures,&lt;br&gt;
connected historical cases,&lt;br&gt;
graph components,&lt;br&gt;
customer context,&lt;br&gt;
transaction context,&lt;br&gt;
historical-case candidates.&lt;/p&gt;
&lt;p&gt;GraphRAG then adds semantic retrieval over structurally relevant cases and policy documents.&lt;/p&gt;
&lt;p&gt;Importantly:&lt;/p&gt;
&lt;p&gt;Precedent is context. It is not proof.&lt;/p&gt;
&lt;p&gt;A similar historical fraud case can help an investigator understand the situation.&lt;/p&gt;
&lt;p&gt;It cannot prove the current customer committed fraud.&lt;/p&gt;
&lt;p&gt;TigerGraph MCP — not just a checkbox&lt;/p&gt;
&lt;p&gt;Near the end of the build, I realized something uncomfortable.&lt;/p&gt;
&lt;p&gt;I had used TigerGraph MCP during development, but the investigation runtime was still calling the same installed GSQL queries directly through REST++.&lt;/p&gt;
&lt;p&gt;The challenge explicitly required MCP.&lt;/p&gt;
&lt;p&gt;I could have documented that MCP was used during development and moved on.&lt;/p&gt;
&lt;p&gt;Instead, I rebuilt the graph transport boundary.&lt;/p&gt;
&lt;p&gt;The important constraint was:&lt;/p&gt;
&lt;p&gt;MCP integration was not allowed to change a single investigation decision.&lt;/p&gt;
&lt;p&gt;So I introduced a separate transport adapter.&lt;/p&gt;
&lt;p&gt;The investigator itself does not know whether the result came from REST++ or MCP.&lt;/p&gt;
&lt;p&gt;Both transports return the same normalized graph results.&lt;/p&gt;
&lt;p&gt;Then I tested them.&lt;/p&gt;
&lt;p&gt;All 7 installed read queries returned identical normalized results over REST and MCP.&lt;/p&gt;
&lt;p&gt;Then I ran the entire benchmark over both transports.&lt;/p&gt;
&lt;p&gt;The final MCP run made:&lt;/p&gt;
&lt;p&gt;96 graph calls&lt;/p&gt;
&lt;p&gt;0 MCP failures&lt;/p&gt;
&lt;p&gt;0 semantic differences between REST and MCP&lt;/p&gt;
&lt;p&gt;0 semantic differences between MCP and the approved release&lt;/p&gt;
&lt;p&gt;Different transport.&lt;/p&gt;
&lt;p&gt;Same evidence.&lt;/p&gt;
&lt;p&gt;Same decisions.&lt;/p&gt;
&lt;p&gt;That was the point where MCP became a genuine part of the CaseVera runtime rather than a feature listed in a README.&lt;/p&gt;
&lt;p&gt;The backend was the hardest part&lt;/p&gt;
&lt;p&gt;The polished UI is probably the first thing people will notice.&lt;/p&gt;
&lt;p&gt;The backend is where most of the project actually happened.&lt;/p&gt;
&lt;p&gt;And it was painful.&lt;/p&gt;
&lt;p&gt;More than once, I reached a stage where the system looked finished.&lt;/p&gt;
&lt;p&gt;Then another assumption broke.&lt;/p&gt;
&lt;p&gt;A relationship that seemed obvious from the data could not actually be proven.&lt;/p&gt;
&lt;p&gt;Historical information had to obey strict point-in-time rules so an investigation could not learn from the future.&lt;/p&gt;
&lt;p&gt;Customer-level card ownership could not automatically be treated as transaction-level card attribution.&lt;/p&gt;
&lt;p&gt;Historical precedent had to remain context rather than current-case evidence.&lt;/p&gt;
&lt;p&gt;Simulated customer replies had to remain hypothetical and could never silently become observed evidence.&lt;/p&gt;
&lt;p&gt;Graph write-back had to be idempotent.&lt;/p&gt;
&lt;p&gt;A successful write was not enough; the written state had to be independently read back and reconciled.&lt;/p&gt;
&lt;p&gt;Even after the decision engine was finalized, integrating MCP briefly changed the release fingerprint because one Python file was re-saved with different Windows line endings.&lt;/p&gt;
&lt;p&gt;The code behaved the same.&lt;/p&gt;
&lt;p&gt;The bytes did not.&lt;/p&gt;
&lt;p&gt;So the fingerprint failed.&lt;/p&gt;
&lt;p&gt;I restored the original bytes before continuing.&lt;/p&gt;
&lt;p&gt;At the time it was extremely frustrating.&lt;/p&gt;
&lt;p&gt;In retrospect, it captured the entire philosophy of the project:&lt;/p&gt;
&lt;p&gt;“Seems correct” was never enough. I wanted it proven.&lt;/p&gt;
&lt;p&gt;The moment the system finally clicked&lt;/p&gt;
&lt;p&gt;The project became much clearer once I stopped thinking of it as:&lt;/p&gt;
&lt;p&gt;a fraud classifier&lt;/p&gt;
&lt;p&gt;and started thinking of it as:&lt;/p&gt;
&lt;p&gt;an investigation loop.&lt;/p&gt;
&lt;p&gt;That changed everything.&lt;/p&gt;
&lt;p&gt;The model was allowed to be wrong without destroying the system.&lt;/p&gt;
&lt;p&gt;GraphRAG could provide useful context without becoming evidence.&lt;/p&gt;
&lt;p&gt;Uncertainty became a legitimate outcome.&lt;/p&gt;
&lt;p&gt;Human approval was designed into the workflow.&lt;/p&gt;
&lt;p&gt;Evidence requests became part of the agent’s behavior.&lt;/p&gt;
&lt;p&gt;And every investigation could end with a structured, auditable decision record.&lt;/p&gt;
&lt;p&gt;The agent could now effectively say:&lt;/p&gt;
&lt;p&gt;“I see something suspicious, but I do not yet have enough evidence.”&lt;/p&gt;
&lt;p&gt;That is much more useful than forcing every case into fraud or legitimate.&lt;/p&gt;
&lt;p&gt;Next-best action matters as much as the verdict&lt;/p&gt;
&lt;p&gt;A fraud investigator does not only need:&lt;/p&gt;
&lt;p&gt;“Fraud / not fraud.”&lt;/p&gt;
&lt;p&gt;They need:&lt;/p&gt;
&lt;p&gt;“What do we do now?”&lt;/p&gt;
&lt;p&gt;CaseVera therefore produces both an initial and final next-best action.&lt;/p&gt;
&lt;p&gt;Possible actions include:&lt;/p&gt;
&lt;p&gt;allow,&lt;br&gt;
monitor,&lt;br&gt;
verify,&lt;br&gt;
step-up authentication,&lt;br&gt;
decline,&lt;br&gt;
block a card,&lt;br&gt;
escalate,&lt;br&gt;
create a case,&lt;br&gt;
file a report,&lt;br&gt;
close with no fraud.&lt;/p&gt;
&lt;p&gt;Actions also have approval routes:&lt;/p&gt;
&lt;p&gt;auto&lt;/p&gt;
&lt;p&gt;L1&lt;/p&gt;
&lt;p&gt;L2&lt;/p&gt;
&lt;p&gt;The agent can recommend restricted actions.&lt;/p&gt;
&lt;p&gt;It cannot pretend a human-approved action has already happened.&lt;/p&gt;
&lt;p&gt;SAR generation is also policy-controlled rather than triggered by a language model.&lt;/p&gt;
&lt;p&gt;That separation became particularly important once I started thinking of CaseVera as something that could eventually operate in a real financial environment.&lt;/p&gt;
&lt;p&gt;Case memory closes the loop&lt;/p&gt;
&lt;p&gt;An investigation should not disappear once the decision is made.&lt;/p&gt;
&lt;p&gt;CaseVera writes completed investigations back into TigerGraph as:&lt;/p&gt;
&lt;p&gt;an InvestigationCase,&lt;br&gt;
associated Evidence vertices,&lt;br&gt;
relationships to transactions,&lt;br&gt;
customers,&lt;br&gt;
cards,&lt;br&gt;
historical precedents,&lt;br&gt;
documents,&lt;br&gt;
devices.&lt;/p&gt;
&lt;p&gt;For the benchmark, the 20 cases remain isolated from each other to maintain point-in-time fairness.&lt;/p&gt;
&lt;p&gt;But the write side already establishes the foundation for a future system where newly completed investigations become usable institutional memory.&lt;/p&gt;
&lt;p&gt;The final production write was independently reconciled:&lt;/p&gt;
&lt;p&gt;20/20 investigation cases complete&lt;/p&gt;
&lt;p&gt;273 evidence vertices&lt;/p&gt;
&lt;p&gt;0 reconciliation divergences&lt;/p&gt;
&lt;p&gt;Replay tests also verified that repeating writes did not create semantic growth.&lt;/p&gt;
&lt;p&gt;The UI: show the investigation, not just the answer&lt;/p&gt;
&lt;p&gt;I wanted the UI to feel like an analyst workstation rather than another fraud dashboard.&lt;/p&gt;
&lt;p&gt;CaseVera includes:&lt;/p&gt;
&lt;p&gt;case queue,&lt;br&gt;
overview,&lt;br&gt;
evidence graph,&lt;br&gt;
evidence ledger,&lt;br&gt;
policy decision view,&lt;br&gt;
investigation replay,&lt;br&gt;
policy corpus,&lt;br&gt;
release audit.&lt;/p&gt;
&lt;p&gt;The graph view shows connected entities.&lt;/p&gt;
&lt;p&gt;The evidence ledger separates fraud, legitimacy and ambiguity.&lt;/p&gt;
&lt;p&gt;The decision view explains which policy produced each recommendation.&lt;/p&gt;
&lt;p&gt;Replay shows the sequence of graph retrieval, evidence gathering and decision formation.&lt;/p&gt;
&lt;p&gt;The release audit exposes the boring but important stuff:&lt;/p&gt;
&lt;p&gt;hashes, reconciliation, persistence and reproducibility.&lt;/p&gt;
&lt;p&gt;The current browser UI is deliberately a verified read-only snapshot rather than pretending it is connected live to TigerGraph.&lt;/p&gt;
&lt;p&gt;What was validated&lt;/p&gt;
&lt;p&gt;The benchmark labels are hidden.&lt;/p&gt;
&lt;p&gt;So I will not claim an accuracy number I cannot prove.&lt;/p&gt;
&lt;p&gt;Instead, I validated what I could actually measure:&lt;/p&gt;
&lt;p&gt;20/20 answer files produced and schema-validated&lt;/p&gt;
&lt;p&gt;20/20 cases persisted and independently reconciled&lt;/p&gt;
&lt;p&gt;273 Evidence vertices&lt;/p&gt;
&lt;p&gt;7/7 graph queries identical over REST and MCP&lt;/p&gt;
&lt;p&gt;96 MCP runtime calls, 0 failures&lt;/p&gt;
&lt;p&gt;0 semantic differences between MCP and REST&lt;/p&gt;
&lt;p&gt;0 semantic differences between MCP and the approved release&lt;/p&gt;
&lt;p&gt;idempotent write replay verified&lt;/p&gt;
&lt;p&gt;content-addressed scoring and persistence releases&lt;/p&gt;
&lt;p&gt;To me, this matters more than inventing a benchmark score.&lt;/p&gt;
&lt;p&gt;CaseVera is not perfect&lt;/p&gt;
&lt;p&gt;There are several things I would build next.&lt;/p&gt;
&lt;p&gt;First, real analyst and customer interaction instead of simulated responses.&lt;/p&gt;
&lt;p&gt;Second, an LLM-powered interaction layer that lets analysts ask natural-language questions and receive explanations grounded only in verified case state.&lt;/p&gt;
&lt;p&gt;Third, newly written InvestigationCase memory should become directly retrievable by later production investigations.&lt;/p&gt;
&lt;p&gt;Fourth, stronger model calibration and additional fraud typologies.&lt;/p&gt;
&lt;p&gt;Fifth, streaming triggers and richer analyst feedback loops.&lt;/p&gt;
&lt;p&gt;And eventually, controlled dynamic tool selection — but only where the tool choices and evidence remain auditable.&lt;/p&gt;
&lt;p&gt;The system I submitted is therefore not the end state.&lt;/p&gt;
&lt;p&gt;It is the architecture I want to build that end state on.&lt;/p&gt;
&lt;p&gt;Why I want to take CaseVera further&lt;/p&gt;
&lt;p&gt;I do not think CaseVera should move forward because it has the flashiest AI model.&lt;/p&gt;
&lt;p&gt;It does not.&lt;/p&gt;
&lt;p&gt;I think its strength is that I treated the challenge as an investigation-engineering problem rather than an API demo.&lt;/p&gt;
&lt;p&gt;I used TigerGraph where relationships genuinely mattered.&lt;/p&gt;
&lt;p&gt;GraphRAG where retrieval genuinely mattered.&lt;/p&gt;
&lt;p&gt;MCP as an actual runtime graph transport.&lt;/p&gt;
&lt;p&gt;A statistical model where probabilistic suspicion was useful.&lt;/p&gt;
&lt;p&gt;Deterministic controls where mistakes would be expensive.&lt;/p&gt;
&lt;p&gt;And when the system contradicted one of my assumptions, I changed the assumption instead of forcing the output I expected.&lt;/p&gt;
&lt;p&gt;There is still a lot I would like to improve.&lt;/p&gt;
&lt;p&gt;But after spending most of this hackathon debugging edge cases, evidence provenance, graph relationships, temporal leakage, persistence and policy boundaries, the foundation now has one property I care about more than raw confidence:&lt;/p&gt;
&lt;p&gt;traceability.&lt;/p&gt;
&lt;p&gt;The biggest lesson I am taking away from this build is:&lt;/p&gt;
&lt;p&gt;The most intelligent system is not always the one that answers fastest. Sometimes it is the one that knows when it does not know enough.&lt;/p&gt;
&lt;p&gt;That is what I want CaseVera to become.&lt;/p&gt;
&lt;p&gt;CaseVera — Investigate the case. Not just the score.&lt;/p&gt;
&lt;p&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/BEAST04289/CaseVera&quot;&gt;https://github.com/BEAST04289/CaseVera&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Built for TigerGraph × Hacker House Goa 2026.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29969/i-didnt-want-ai-to-decide-fraud-i-wanted-it-to-investigate-it</guid>
<pubDate>Tue, 06 Oct 2026 09:50:36 +0000</pubDate>
</item>
<item>
<title>One Rails App, Multiple Customers</title>
<link>https://coderlegion.com/29815/one-rails-app-multiple-customers</link>
<description>&lt;p&gt; Why Multi-Tenancy Matters&lt;/p&gt;
&lt;p&gt;Imagine you are building a SaaS application.&lt;/p&gt;
&lt;p&gt;You have &lt;strong&gt;100 companies&lt;/strong&gt;, but you don't want to deploy and maintain 100 separate applications.&lt;/p&gt;
&lt;p&gt;That's where &lt;strong&gt;Multi-Tenancy&lt;/strong&gt; comes in.&lt;/p&gt;
&lt;h4 id=&quot;what-is-multi-tenancy-&quot;&gt;What is Multi-Tenancy?&lt;/h4&gt;
&lt;p&gt;Multi-tenancy allows a single application to serve multiple customers — called &lt;strong&gt;tenants&lt;/strong&gt; — while keeping each tenant's data isolated.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                    Rails Application
                           │
          ┌────────────────┼────────────────┐
          ↓                ↓                ↓
       Tenant A         Tenant B         Tenant C
       Company A        Company B        Company C
          │                │                │
       Users/Data       Users/Data       Users/Data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each company uses the same application, but should only be able to access &lt;strong&gt;its own data&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;how-can-we-implement-it-with-rails-&quot;&gt;How can we implement it with Rails?&lt;/h4&gt;
&lt;p&gt;A common approach is adding a &lt;code&gt;tenant_id&lt;/code&gt; to tenant-owned tables:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class User &amp;lt; ApplicationRecord
  belongs_to :tenant
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then queries are scoped to the current tenant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;current_tenant.users
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/wz8nqkg6jgu429eyrwr7.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;For larger systems, another approach is &lt;strong&gt;database-per-tenant&lt;/strong&gt;, where each customer gets a separate database.&lt;/p&gt;
&lt;p&gt;The right strategy depends on your scale, security requirements, infrastructure, and operational complexity.&lt;/p&gt;
&lt;h4 id=&quot;why-use-multi-tenancy-&quot;&gt;Why use Multi-Tenancy?&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;1️⃣ Lower Infrastructure Costs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;One application can serve many customers instead of running separate deployments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2️⃣ Easier Maintenance&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Deploy features and bug fixes once instead of updating every customer environment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3️⃣ Faster Scaling&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You can scale application servers and background workers as the customer base grows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4️⃣ Better SaaS Architecture&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Multi-tenancy fits naturally with subscription-based products where many businesses share the same platform.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5️⃣ Centralized Development&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;One codebase means consistent features, security updates, and improvements across tenants.&lt;/p&gt;
&lt;h4 id=&quot;-the-important-part&quot;&gt;⚠️ The important part&lt;/h4&gt;
&lt;p&gt;Multi-tenancy isn't simply adding a &lt;code&gt;tenant_id&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You need to think about:&lt;/p&gt;
&lt;p&gt; Data isolation&lt;br&gt;
️ Authorization&lt;br&gt;
️ Database architecture&lt;br&gt;
⚡ Caching&lt;br&gt;
 Background jobs&lt;br&gt;
 Reporting&lt;br&gt;
 Tenant-aware integrations&lt;/p&gt;
&lt;p&gt;A single missing tenant filter can become a serious data-isolation problem.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29815/one-rails-app-multiple-customers</guid>
<pubDate>Mon, 05 Oct 2026 08:00:48 +0000</pubDate>
</item>
<item>
<title>A Row Version Is a Cross-Layer Contract</title>
<link>https://coderlegion.com/29596/a-row-version-is-a-cross-layer-contract</link>
<description>&lt;p&gt;A concurrency column can exist in the database and still be wrong.&lt;/p&gt;
&lt;p&gt;The name may match. The migration may have run. Inserts may appear healthy. Yet optimistic concurrency can still be weaker than the application assumes because the runtime model, the physical schema, and the test environment disagree about what that column means.&lt;/p&gt;
&lt;p&gt;The lesson is broader than one ORM feature: database-generated values are cross-layer contracts. Treating them as a property of only the entity class or only the table leaves a gap exactly where correctness matters.&lt;/p&gt;
&lt;h3 id=&quot;what-the-contract-actually-contains&quot;&gt;What the contract actually contains&lt;/h3&gt;
&lt;p&gt;For a database-generated row version, at least five facts need to agree:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the physical type has true row-version semantics;&lt;/li&gt;
&lt;li&gt;the value is non-nullable;&lt;/li&gt;
&lt;li&gt;the database generates it on insert and update;&lt;/li&gt;
&lt;li&gt;the ORM marks it as a concurrency token;&lt;/li&gt;
&lt;li&gt;existing values remain intact during alignment or deployment.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A binary column of the expected length is not automatically equivalent. A nullable token changes the invariant. A model that knows about concurrency but not database generation can try to write a value the database owns. Conversely, a correctly generated database column provides little application protection if the runtime model never includes it in optimistic-concurrency checks.&lt;/p&gt;
&lt;p&gt;The useful mental model is not “there is a column.” It is “every layer agrees who owns this value and how a stale write is detected.”&lt;/p&gt;
&lt;h3 id=&quot;prefer-verification-over-destructive-repair&quot;&gt;Prefer verification over destructive repair&lt;/h3&gt;
&lt;p&gt;Suppose the shipped database already has the correct non-null generated token, but the current EF Core model describes it incompletely. A tempting fix is to alter or recreate the column until the migration history looks tidy.&lt;/p&gt;
&lt;p&gt;That can be the riskiest part of the change. Replacing a generated token can touch every row, invalidate values held by active writers, or disguise an unexpected schema. The safer move is often smaller:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;align the runtime mapping with the physical contract;&lt;/li&gt;
&lt;li&gt;add a forward-only migration that asserts the exact expected shape;&lt;/li&gt;
&lt;li&gt;refuse to proceed when the real schema differs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This kind of assertion migration changes no business data. It asks the database whether the table, column, type, length, nullability, and generation semantics match the known contract. Running it twice should be safe. Encountering a missing, nullable, or look-alike column should fail clearly.&lt;/p&gt;
&lt;p&gt;Failing a rollout is inconvenient. Quietly weakening stale-write protection is worse. A fail-closed migration turns unknown drift into an explicit operational decision instead of an accidental data-integrity change.&lt;/p&gt;
&lt;h3 id=&quot;test-at-three-different-levels&quot;&gt;Test at three different levels&lt;/h3&gt;
&lt;p&gt;No single test provider can prove the whole contract efficiently. A layered suite gives each test one honest job.&lt;/p&gt;
&lt;h4 id=&quot;1-model-metadata&quot;&gt;1. Model metadata&lt;/h4&gt;
&lt;p&gt;A cheap unit test can inspect EF Core metadata. It should verify that the property is required, marked as a concurrency token, and generated on add or update. This catches accidental mapping changes quickly, without starting a database.&lt;/p&gt;
&lt;p&gt;Metadata tests do not prove the database behaves that way. They prove the application describes its expectation correctly.&lt;/p&gt;
&lt;h4 id=&quot;2-lightweight-application-flows&quot;&gt;2. Lightweight application flows&lt;/h4&gt;
&lt;p&gt;In-memory providers are useful for service and orchestration tests, but they do not automatically behave like a relational engine with generated row versions. Once a token becomes required, fixtures may fail because no value is produced.&lt;/p&gt;
&lt;p&gt;A narrowly scoped, test-only save interceptor can generate a fresh fixed-length token for inserted or modified entities. That keeps fast tests representative of the required-value lifecycle. It should be described honestly as an emulator, not provider parity.&lt;/p&gt;
&lt;p&gt;The emulator proves that application code can carry changing tokens through its flows. It cannot prove the SQL type, migration assertion, or actual stale-write mechanics.&lt;/p&gt;
&lt;h4 id=&quot;3-a-focused-real-database-proof&quot;&gt;3. A focused real-database proof&lt;/h4&gt;
&lt;p&gt;The decisive test uses the real provider. Create the shipped shape, insert a synthetic row, and retain its generated token. Apply the alignment migration—ideally twice—and verify that both the row and original token survive.&lt;/p&gt;
&lt;p&gt;Then load the same row through two contexts. Save through the first and confirm its token changes. Attempt to save the stale copy through the second. The expected concurrency exception is the behavioural proof that the contract works end to end.&lt;/p&gt;
&lt;p&gt;Negative cases matter too. A missing table, missing column, nullable token, or ordinary binary column should be refused. These cases prevent a permissive assertion from becoming ceremonial.&lt;/p&gt;
&lt;h3 id=&quot;put-the-expensive-proof-on-the-required-path&quot;&gt;Put the expensive proof on the required path&lt;/h3&gt;
&lt;p&gt;A real-database test that developers can skip forever is documentation, not a gate. Register the small provider-faithful suite in the required CI job and include the relevant file scopes in change detection.&lt;/p&gt;
&lt;p&gt;This does not mean every application test should use a full database. That would make feedback slower and fixtures harder to maintain. The trade-off is deliberate separation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;metadata tests are cheap and broad;&lt;/li&gt;
&lt;li&gt;in-memory tests exercise application flows;&lt;/li&gt;
&lt;li&gt;real-database tests prove database-owned behaviour.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The suite costs more than one happy-path unit test. In return, failures become easier to interpret because each layer states what it can and cannot guarantee.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When reviewing a database-generated concurrency token, ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does the runtime model match the shipped type, nullability, and generation rules?&lt;/li&gt;
&lt;li&gt;Does the migration preserve rows and existing token values?&lt;/li&gt;
&lt;li&gt;Does unexpected schema fail closed?&lt;/li&gt;
&lt;li&gt;Do fast fixtures represent the required token lifecycle without claiming provider equivalence?&lt;/li&gt;
&lt;li&gt;Does a real-provider test prove token change and stale-write rejection?&lt;/li&gt;
&lt;li&gt;Is that proof part of required CI?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The broader engineering habit is simple: when a value is owned by the database, verify the contract where each promise is made. Model tests describe intent. Fast tests protect application flow. Only the real provider can prove the final integrity boundary.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29596/a-row-version-is-a-cross-layer-contract</guid>
<pubDate>Sat, 03 Oct 2026 06:24:18 +0000</pubDate>
</item>
<item>
<title>Demystifying ACID Transactions: How to Keep Your Database from Making a Mess</title>
<link>https://coderlegion.com/29382/demystifying-acid-transactions-how-to-keep-your-database-from-making-a-mess</link>
<description>&lt;p&gt;An ACID transaction is a set of four database safety rules—Atomicity, Consistency, Isolation, and Durability—that ensure database operations are executed reliably. It guarantees that even if your application crashes, loses power, or experiences thousands of concurrent requests, your data remains accurate and uncorrupted. Simply put, it ensures your database never ends up in a half-finished, broken state.&lt;/p&gt;
&lt;h4 id=&quot;the-ticket-booth-analogy&quot;&gt;The Ticket Booth Analogy&lt;/h4&gt;
&lt;p&gt;Imagine buying a physical ticket at a busy movie theater box office. You hand the cashier a $15 bill, and they hand you the ticket. This exchange must happen as a single, indivisible unit. If you hand over your cash, but a sudden power outage occurs before the cashier can hand you the ticket, you would be furious if they kept your money. Conversely, the theater would lose out if you grabbed the ticket and walked away without paying.&lt;/p&gt;
&lt;p&gt;The transaction is &quot;all-or-nothing&quot;: either the cash changes hands &lt;em&gt;and&lt;/em&gt; you get the ticket, or the entire deal is canceled, and you keep your money. ACID acts as this strict box-office coordinator for your software's database, ensuring that multi-step operations never stop halfway.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-real-world-disasters&quot;&gt;Why It Matters: Real-World Disasters&lt;/h4&gt;
&lt;p&gt;In daily software engineering, we rely on ACID transactions to prevent catastrophic data errors, especially when building systems like financial ledgers, e-commerce checkouts, or reservation platforms.&lt;/p&gt;
&lt;p&gt;Without ACID, imagine a bank transfer system built on Node.js. When a user transfers $100, the system must perform two separate actions: deduct $100 from Account A, and add $100 to Account B. If the database server loses connection exactly halfway through this process, Account A loses money, but Account B never receives it. The $100 vanishes into the digital void.&lt;/p&gt;
&lt;p&gt;Similarly, in a high-traffic retail store, if two customers try to buy the very last item in stock at the exact same millisecond, a non-ACID database might let both transactions complete. This leaves you with an oversold item and an angry customer. By grouping these operations inside an ACID transaction, developers ensure that if any step fails, the database automatically rolls back all changes, restoring everything to its original state.&lt;/p&gt;
&lt;h4 id=&quot;implementing-acid-in-node-js-and-mysql&quot;&gt;Implementing ACID in Node.js and MySQL&lt;/h4&gt;
&lt;p&gt;Here is how we can implement a secure money transfer using Node.js and the standard &lt;code&gt;mysql2/promise&lt;/code&gt; library. By wrapping our queries in a transaction block, we guarantee data safety.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const mysql = require('mysql2/promise');

async function transferFunds(fromAccountId, toAccountId, amount) {
  const connection = await mysql.createConnection({
    host: 'localhost',
    user: 'root',
    database: 'bank_db'
  });

  try {
    // Start the ACID transaction
    await connection.beginTransaction();

    // Step 1: Deduct from the sender's account
    const [deductResult] = await connection.execute(
      'UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance &amp;gt;= ?',
      [amount, fromAccountId, amount]
    );

    if (deductResult.affectedRows === 0) {
      throw new Error('Insufficient funds or invalid sender account');
    }

    // Step 2: Add to the receiver's account
    const [creditResult] = await connection.execute(
      'UPDATE accounts SET balance = balance + ? WHERE id = ?',
      [amount, toAccountId]
    );

    if (creditResult.affectedRows === 0) {
      throw new Error('Invalid receiver account');
    }

    // If both steps succeed, permanently commit the changes to disk
    await connection.commit();
    console.log('Transaction completed successfully!');
  } catch (error) {
    // If anything fails, revert all changes immediately
    await connection.rollback();
    console.error('Transaction failed. Rolled back changes:', error.message);
  } finally {
    await connection.end();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-takeaway&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;ACID transactions are the ultimate insurance policy for your application's state. While they introduce a minor performance trade-off due to locking rows during updates, they completely eliminate the need to write complex, error-prone fallback code. Shifting the responsibility of data integrity to your database engine lets you sleep easy knowing your system will never tell a lie.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/10/data-integrity-101-why-acid.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29382/demystifying-acid-transactions-how-to-keep-your-database-from-making-a-mess</guid>
<pubDate>Thu, 01 Oct 2026 10:28:33 +0000</pubDate>
</item>
<item>
<title>Forensic Aviation Analytics: Auditing Fuel Bleeds &amp; Delay Cascades on a Regional A320neo Network</title>
<link>https://coderlegion.com/28910/forensic-aviation-analytics-auditing-fuel-bleeds-delay-cascades-regional-a320neo-network</link>
<description>&lt;p&gt;When evaluating airline performance, typical analytics dashboards rely blindly on passenger-facing &quot;On-Time&quot; arrival percentages. However, from an operational and financial engineering standpoint, these high-level metrics can easily mask millions of dollars in silent cash destruction. &lt;/p&gt;
&lt;p&gt;Due to strategic schedule padding, a short-haul flight can sit idling its engines on a congested tarmac for 20 minutes, yet still be registered as &quot;On-Time&quot; to the customer. The passenger is happy, but the airline’s finance department just watched pure margin vaporize into thin air.&lt;/p&gt;
&lt;p&gt;To expose these hidden friction layers, I engineered LCC-Ops: an end-to-end data warehouse auditing over 120,000 regional transaction legs modeled around the single-aisle network performance of flynas across key GCC hubs.&lt;/p&gt;
&lt;p&gt;Here is the technical architecture and the forensic database methodologies behind the pipeline.&lt;/p&gt;
&lt;h3 id=&quot;the-tech-stack-amp-architecture&quot;&gt;The Tech Stack &amp;amp; Architecture&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Database Layer:&lt;/strong&gt; MySQL Server 8.0 (Advanced CTEs, Mathematical Window Functions, Schema Migrations)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Business Intelligence:&lt;/strong&gt; Power BI Desktop (Data Type Optimization, Cross-Table Iterators, UI Design)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Core Fleet Constants:&lt;/strong&gt; Engineered around the Airbus A320neo (CFM LEAP-1A26 engine) utilizing official ICAO Engine Emissions Databank metrics. At a standard 7% ground idle taxi thrust, this engine burns exactly 0.0940 kg of fuel per second, per engine&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-database-engineering-breakdown&quot;&gt;The Database Engineering Breakdown&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Pillar 1&lt;/strong&gt;: Isolating Tarmac Cash Destruction (Domain A)To find the true cost of airport ground bottlenecks, the ingestion engine isolates rows inside the transactions table (fact_flights) where the Out-Gate to Runway Takeoff latency (taxi_out) exceeded a standard 4-minute engine stabilization and warming buffer.&lt;/p&gt;
&lt;p&gt;By pulling the real-world ICAO fuel coefficients down via relational keys, the calculation bypasses text-based padding and computes raw physical fuel destruction.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT 
    f.origin AS departure_hub,
    COUNT(f.tail_number) AS total_monitored_flights,
    ROUND(AVG(f.taxi_out), 2) AS avg_taxi_out_minutes,
    -- Formula: (Actual Taxi Time - 4 Min Buffer) * 60 Sec * ICAO Idle Flow Rate * 2 Engines
    ROUND(SUM((f.taxi_out - 4) * 60 * e.fuel_flow_idle_kg_sec * 2), 2) AS total_wasted_fuel_kg
FROM fact_flights f
JOIN dim_aircraft a ON f.tail_number = a.tail_number
JOIN dim_engine e ON a.engine_identification = e.engine_identification
WHERE f.reporting_airline = 'XY' AND f.taxi_out &amp;gt; 4
GROUP f.origin;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pillar 2: Tracking Cross-Border Delay Cascades (Domain D)&lt;/strong&gt;&lt;br&gt;
When an international flight arriving from an airport like Karachi (KHI) experiences customs or airspace delays, that latency risks cascading into the aircraft’s subsequent domestic legs (e.g., RUH ➔ JED).&lt;/p&gt;
&lt;p&gt;To track this chronologically across individual daily airframe lifecycles, I utilized advanced LEAD() mathematical window functions&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WITH ranked_daily_schedule AS (
    SELECT 
        flight_date, tail_number, origin, dest, carrier_delay AS leg_1_delay,
        LEAD(origin, 1) OVER (PARTITION BY tail_number, flight_date ORDER BY taxi_out DESC) AS leg_2_origin,
        LEAD(carrier_delay, 1) OVER (PARTITION BY tail_number, flight_date ORDER BY taxi_out DESC) AS leg_2_delay
    FROM fact_flights
    WHERE reporting_airline = 'XY'
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pillar 3: Menu Logistics &amp;amp; Hangar Risk Automation (Domains B &amp;amp; C)&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Catering Spoilage Optimization:&lt;/em&gt; Short flight windows on regional tracks mean cabin crews lack the time to complete complex perishable sales loops. The data models validate flynas’s lean, shelf-stable buy-on-board menu model, proving that switching to fresh profiles would trigger an immediate &lt;strong&gt;54% inventory spoilage cash bleed&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt; &lt;em&gt;Automated Hangar Reorder Points (ROP):&lt;/em&gt; The warehouse tracks rolling airframe hours against specific tail numbers (HZ-NS1 to HZ-NS5) [Query-relevant Context]. If a component approaches its high-wear safety limit, the system triggers automated database alerts to protect the airline against catastrophic &lt;strong&gt;$25,000/day Aircraft on Ground (AOG) hangar penalties&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;ui-engineering-in-power-bi&quot;&gt;UI Engineering in Power BI&lt;/h3&gt;
&lt;p&gt;To present these data layers to corporate stakeholders, I built an interactive application-style interface directly inside Power BI using flynas’s signature branding tones.&lt;/p&gt;
&lt;p&gt;The biggest challenge during BI ingestion was data type conversions—empty strings or trailing spaces in the raw logs caused math functions to drop blank values (--). By implementing upfront string transformations (Uppercase/Trim) and embedding data validation wrappers like ISNUMBER(VALUE()) directly into row-level DAX iterators, the front-end dynamically scales across massive transaction volumes without data loss.&lt;/p&gt;
&lt;p&gt;The entire production code schema and scripts are live on my GitHub &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hafsamotiwala/flynas-fleet-telemetry-analytics&quot;&gt;Link&lt;/a&gt;. I'd love to hear how other data engineers manage real-time fuel-burn parameters or multi-leg network routing in your pipelines!&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/28910/forensic-aviation-analytics-auditing-fuel-bleeds-delay-cascades-regional-a320neo-network</guid>
<pubDate>Mon, 28 Sep 2026 11:41:49 +0000</pubDate>
</item>
<item>
<title>Database Concurrency 101: Optimistic vs. Pessimistic Locking</title>
<link>https://coderlegion.com/28791/database-concurrency-101-optimistic-vs-pessimistic-locking</link>
<description>&lt;p&gt;In modern software development, we often have to handle multiple users interacting with our application at the exact same time. Optimistic and pessimistic locking are two distinct strategies used to manage concurrency, which is the technical term for when multiple users or systems try to update the exact same database record simultaneously. While optimistic locking assumes conflicts are rare and verifies changes only at the very end of an operation, pessimistic locking assumes conflicts are highly likely and preemptively blocks others from accessing the data until the current operation is finished.&lt;/p&gt;
&lt;h4 id=&quot;a-relatable-everyday-analogy&quot;&gt;A Relatable Everyday Analogy&lt;/h4&gt;
&lt;p&gt;To understand the difference, imagine a collaborative digital workspace where you and your colleagues edit text files.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pessimistic locking&lt;/strong&gt; is like an old-school file server where only one person can open a document for editing at a time. The moment you open the file, the server locks it. If your colleague tries to open it, they receive a &quot;Read-Only&quot; warning and are completely blocked from making edits until you save, close, and release your lock on the file.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Optimistic locking&lt;/strong&gt;, on the other hand, is like a modern version control system like Git. Both you and your colleague can open, read, and edit the exact same document simultaneously. However, when you click &quot;Save,&quot; the system checks if anyone else has saved a new version of the document since you opened it. If no one has, your save succeeds. If your colleague managed to save their changes first, the system blocks your save, alerts you to a &quot;conflict,&quot; and asks you to review and merge the changes before trying again.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-in-the-tech-industry&quot;&gt;Why It Matters in the Tech Industry&lt;/h4&gt;
&lt;p&gt;In the tech industry, failing to implement a proper locking strategy leads to a highly destructive bug known as a &quot;lost update&quot; or a &quot;race condition&quot;—where the outcome of a process depends on the unpredictable sequence or timing of other events. &lt;/p&gt;
&lt;p&gt;Imagine a financial application where two family members try to withdraw $50 from a shared $100 bank account at the exact same millisecond. Without locking, both transactions will read the balance as $100 simultaneously. Both transactions will approve the $50 withdrawal, and both will attempt to write the new balance of $50 back to the database. The bank is now down $100, but the database incorrectly shows a remaining balance of $50 because the second write silently overwrote the first one. By using database locking, software engineers prevent this double-spending, protect system integrity, and ensure transactions are processed safely.&lt;/p&gt;
&lt;h4 id=&quot;implementation-in-node-js-and-mysql&quot;&gt;Implementation in Node.js and MySQL&lt;/h4&gt;
&lt;p&gt;Here is a simple example of how to implement &lt;strong&gt;optimistic locking&lt;/strong&gt; in a Node.js application using a MySQL database client. We do this by adding a &lt;code&gt;version&lt;/code&gt; column to our database table and verifying it during our update query.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// A helper function to update a user's bank balance using Optimistic Locking
async function updateAccountBalance(connection, accountId, withdrawAmount, currentVersion) {
  // We attempt to update the balance and increment the version number,
  // but ONLY if the version in the database matches the version we originally read.
  const query = `
    UPDATE bank_accounts 
    SET balance = balance - ?, version = version + 1 
    WHERE id = ? AND version = ?
  `;

  const [result] = await connection.execute(query, [withdrawAmount, accountId, currentVersion]);

  // If no rows were affected, it means the version changed in the background
  // because another transaction updated it first. We must roll back and retry.
  if (result.affectedRows === 0) {
    throw new Error(&quot;Concurrency Conflict: The account was updated by another process. Please retry.&quot;);
  }

  return &quot;Balance updated successfully!&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-strategic-takeaway&quot;&gt;The Strategic Takeaway&lt;/h4&gt;
&lt;p&gt;Choosing between these two approaches is always a trade-off between speed and strict prevention. You should default to optimistic locking for applications with low-to-medium write conflicts because it keeps your application fast, highly available, and free of database bottlenecks. However, when dealing with highly critical operations where conflicts are frequent and data corruption is unacceptable—such as reservation systems or payment gateways—pessimistic locking is the superior choice, despite the performance hit of making users wait in a digital queue.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/09/how-to-handle-concurrent-writes.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28791/database-concurrency-101-optimistic-vs-pessimistic-locking</guid>
<pubDate>Sun, 27 Sep 2026 09:32:31 +0000</pubDate>
</item>
<item>
<title>Horizontally Scaling PostgreSQL: Database Sharding with Citus</title>
<link>https://coderlegion.com/28711/horizontally-scaling-postgresql-database-sharding-with-citus</link>
<description>&lt;blockquote&gt;&lt;p&gt;A practical guide to scaling your database infrastructure horizontally. Learn how to shard your data, optimize query routing, and build a distributed multi-tenant architecture using Postgres, Citus and Docker.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;What do you need to follow along?&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A working Laptop/PC?&lt;/li&gt;
&lt;li&gt;Working knowledge of SQL or PostgreSQL.&lt;/li&gt;
&lt;li&gt;To have at least eaten something today (preferably swallow related).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Database sharding is a strategy used to solve scalability issues in applications that have a massive amount of data. It involves splitting data into smaller chunks called shards. Each shard is stored on a separate database server instance, effectively spreading the data and the workload across multiple machines.&lt;/p&gt;
&lt;h3 id=&quot;why-shard-data-&quot;&gt;Why Shard Data?&lt;/h3&gt;
&lt;p&gt;As an application grows, the number of users and the amount of data it stores increase over time. At a certain limit, a single database server will become a bottleneck. The application slows down and affects the user experience.&lt;/p&gt;
&lt;p&gt;Instead of upgrading a single server with more CPU or RAM, which will eventually hit a hardware ceiling, sharding lets you add more servers to your cluster. This allows applications to handle nearly infinite growth in data volume and user traffic.&lt;/p&gt;
&lt;h3 id=&quot;understanding-shards-and-shard-keys&quot;&gt;Understanding Shards and Shard Keys&lt;/h3&gt;
&lt;p&gt;Before we start sharding data, let's first understand what shards and shard keys are.&lt;/p&gt;
&lt;h4 id=&quot;shards&quot;&gt;Shards&lt;/h4&gt;
&lt;p&gt;The partitioned data chunks are called logical shards. The machine that stores the logical shard is called a physical shard or database node. A physical shard can contain multiple logical shards.&lt;/p&gt;
&lt;h4 id=&quot;shard-key&quot;&gt;Shard Key&lt;/h4&gt;
&lt;p&gt;A shard key is used to determine how to partition the dataset. A shard key is a specific column in your dataset that determines which rows of data group together to form a shard.&lt;/p&gt;
&lt;h3 id=&quot;sharding-methods&quot;&gt;Sharding Methods&lt;/h3&gt;
&lt;p&gt;There are a few ways to shard data depending on the application requirements.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Key-based sharding (hash sharding)&lt;/strong&gt;: Hash sharding uses a mathematical formula called a hash function. The hash function takes your shard key and generates a hash value, this hash value determines the exact physical shard where the row will be stored.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Range-based sharding&lt;/strong&gt;: Range-based sharding, or dynamic sharding, splits database rows based on a range of values. Then the database designer assigns a shard key to the respective range.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Directory-based sharding&lt;/strong&gt;: Directory sharding uses a lookup table to match database information to the corresponding physical shard. A lookup table is like a table on a spreadsheet that links a database column to a shard key.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Geo sharding&lt;/strong&gt;: Geo sharding splits and stores database information according to geographical location.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For this guide, we are focusing exclusively on &lt;strong&gt;Hash Sharding&lt;/strong&gt;. This is the industry standard for multi-tenant applications like the e-commerce platform we will be building in this tutorial.&lt;/p&gt;
&lt;h3 id=&quot;choosing-your-shard-key&quot;&gt;Choosing Your Shard Key&lt;/h3&gt;
&lt;p&gt;If you pick the wrong shard key, your database will still crash under load. When choosing a shard key, you must ensure the data is distributed evenly across your nodes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cardinality&lt;/strong&gt;: This refers to the number of unique values a key can have. You want a key with high cardinality like a User ID, not low cardinality like a Gender or State column, so the data can be split into many small, even chunks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Frequency&lt;/strong&gt;: You must avoid keys where one specific value appears too often. For example, if you shard by a City column and 80% of your users live in Ikeja, the Ikeja node will be overloaded while the others sit idle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monotonic change&lt;/strong&gt;: This is the rate of change of the shard key. A monotonically increasing or decreasing shard key results in unbalanced shards. For example, consider a feedback database that is split into three different physical shards as follows:&lt;ul&gt;
&lt;li&gt;Shard A stores feedback from customers who have made 0 to 10 purchases.&lt;/li&gt;
&lt;li&gt;Shard B stores feedback from customers who have made 11 to 20 purchases.&lt;/li&gt;
&lt;li&gt;Shard C stores feedback from customers who have made 21 or more purchases.&lt;br&gt;
As the business grows, customers will make 21 or more purchases. The application stores their feedback in Shard C. This results in an unbalanced shard because Shard C contains more feedback records than the other shards.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;is-sharding-actually-for-you-&quot;&gt;Is Sharding Actually For You?&lt;/h3&gt;
&lt;p&gt;While sharding solves massive scale requirements, it introduces significant technical and operational tradeoffs. You should not shard your database unless a single, heavily provisioned Postgres instance can no longer handle your traffic.&lt;/p&gt;
&lt;p&gt;If you do decide to shard your database, here are some of the biggest challenges you will face:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Data hotspots&lt;/strong&gt;: Even with a good hash function, uneven traffic can create hotspots where one server becomes overloaded by a small group of highly active users.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance regression&lt;/strong&gt;: Queries that do not use the shard key require the system to scatter and gather across every server. Cross-shard joins are computationally expensive because the application must pull and merge data from multiple physical locations over the network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operational complexity&lt;/strong&gt;: Managing a sharded environment increases the difficulty of routine tasks. Schema updates, backups, and point-in-time recovery must be coordinated across multiple independent instances.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;how-can-we-shard-our-postgres-database-introducing-citus&quot;&gt;How Can We Shard Our Postgres Database: Introducing Citus&lt;/h3&gt;
&lt;p&gt;Citus is an open-source extension that transforms standard PostgreSQL into a distributed database. Instead of being limited to a single server, Citus allows your database to scale horizontally across multiple machines.&lt;/p&gt;
&lt;h4 id=&quot;how-citus-works&quot;&gt;How Citus Works&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The Coordinator Node&lt;/strong&gt;: When you ask your database a question, you talk to the coordinator. The coordinator directs your request to the right servers, gathers the answers, and combines them for your application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Worker Nodes&lt;/strong&gt;: Citus breaks down large tables into smaller, manageable chunks (shards) and spreads them across these worker servers. They do the heavy lifting of storing the data and running the queries.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;start-early-scale-later&quot;&gt;Start Early, Scale Later&lt;/h4&gt;
&lt;p&gt;Migrating a traditional, monolithic PostgreSQL database to a distributed, sharded database later in its life is incredibly difficult. You have to change your schema, rewrite complex queries, and alter how your application connects to the database.&lt;/p&gt;
&lt;p&gt;To avoid this nightmare, you can start using Citus on a single node from day one. You design your tables with distribution keys immediately, and your application code is written to query a distributed database from the start.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Transition&lt;/strong&gt;: When your data outgrows that single machine, you won't have to rewrite your application. You simply spin up new worker machines, and Citus moves the existing shards over to the new hardware in the background while the application keeps running.&lt;/p&gt;
&lt;h3 id=&quot;running-citus-locally&quot;&gt;Running Citus Locally&lt;/h3&gt;
&lt;p&gt;We will use Docker to run Citus locally. Why use Docker instead of adding the extension directly to your host machine's Postgres installation?&lt;/p&gt;
&lt;p&gt;Simulating a distributed database means running multiple Postgres instances simultaneously. Doing this natively forces you to manually manage conflicting ports and multiple data directories. Docker Compose solves this by orchestrating the entire cluster's networking with a single command, giving you a clean state that can be torn down and rebuilt in seconds.&lt;/p&gt;
&lt;h4 id=&quot;single-node-vs-multi-node-setup&quot;&gt;Single Node vs Multi-Node Setup&lt;/h4&gt;
&lt;p&gt;While it is possible to run a single node Citus instance using a simple &lt;code&gt;docker run&lt;/code&gt; command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run -d --name citus_standalone -p 5432:5432 -e POSTGRES_PASSWORD=postgres citusdata/citus:14.1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This guide will focus exclusively on setting up a &lt;strong&gt;Multi-Node Cluster&lt;/strong&gt;. If you are interested in exploring the single node setup further, you can &lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/postgresql/citus/single-node-docker?view=citus-14&quot;&gt;check out the official Citus documentation here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For our distributed, multi-node cluster, we will use the following Docker Compose configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  master:
    container_name: &quot;${PROJECT_NAME:-citus}_master&quot;
    image: &quot;citusdata/citus:14.1.0&quot;
    ports: [&quot;${COORDINATOR_EXTERNAL_PORT:-5433}:5432&quot;]
    labels: [&quot;com.citusdata.role=Master&quot;]
    environment: &amp;amp;AUTH
      POSTGRES_USER: &quot;${POSTGRES_USER:-postgres}&quot;
      POSTGRES_PASSWORD: &quot;${POSTGRES_PASSWORD}&quot;
      PGUSER: &quot;${POSTGRES_USER:-postgres}&quot;
      PGPASSWORD: &quot;${POSTGRES_PASSWORD}&quot;
      POSTGRES_HOST_AUTH_METHOD: &quot;${POSTGRES_HOST_AUTH_METHOD:-trust}&quot;
  worker:
    image: &quot;citusdata/citus:14.1.0&quot;
    labels: [&quot;com.citusdata.role=Worker&quot;]
    environment: *AUTH
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Citus has a built-in initialization script in its official Docker image. When launched via Docker Compose with these specific labels like &lt;code&gt;com.citusdata.role&lt;/code&gt;, the coordinator automatically detects and registers the workers for you.&lt;/p&gt;
&lt;p&gt;We can build and start the cluster in the background with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can also scale up dynamically. If you want to spin up a second or third worker machine on the fly, you do not even need to edit the compose file. Just run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose up -d --scale worker=3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where 3 is the total number of workers you want to have running.&lt;/p&gt;
&lt;p&gt;Simply spinning up the worker containers is not enough. After adding new worker nodes, we must register them with the coordinator so Citus knows they are active and ready to hold data. To do this, we will first connect to the interactive Postgres terminal on the master node:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose exec master psql -U postgres
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we can register our workers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT citus_add_node('db-sharding-worker-1', 5432);
SELECT citus_add_node('db-sharding-worker-2', 5432);
SELECT citus_add_node('db-sharding-worker-3', 5432);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So, if you scale up again to say 5 workers, you will have to run this command 2 more times to register the 2 new workers.&lt;/p&gt;
&lt;p&gt;Notice the &lt;code&gt;db-sharding-&lt;/code&gt; prefix in the hostnames. This is necessary because Docker Compose prefixes your project folder name to the container's network alias.&lt;/p&gt;
&lt;h3 id=&quot;a-real-world-scenario&quot;&gt;A Real World Scenario&lt;/h3&gt;
&lt;p&gt;In order to showcase the functionality of this database sharding setup, let's try to work with a real world scenario.&lt;/p&gt;
&lt;p&gt;Consider an e-commerce multi-tenant application. It serves multiple independent companies that all share the same database infrastructure. To scale this effectively, we need to use a hash-based distribution with a well-chosen shard key. Let's assume by design, every tenant has a shared &lt;code&gt;company_id&lt;/code&gt;, we will use that as our shard key.&lt;/p&gt;
&lt;p&gt;By using &lt;code&gt;company_id&lt;/code&gt; as the shard key across all operational tables, Citus guarantees that all data belonging to a specific company (like its users, its products, and its orders) is physically co-located on the exact same worker node.&lt;/p&gt;
&lt;p&gt;Now that our workers are registered, we can create our core tables directly in the master node. Citus will automatically handle the underlying sharding for us.&lt;/p&gt;
&lt;p&gt;Please note that the following schema is highly simplified to focus on the core concepts of sharding.&lt;/p&gt;
&lt;p&gt;Run the following SQL commands in your coordinator's postgres interactive terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 1. Create Tenant Table
CREATE TABLE companies (
    company_id INT PRIMARY KEY,
    company_name TEXT NOT NULL
);

-- 2. Create Tenant-Scoped Orders Table
CREATE TABLE orders (
    company_id INT NOT NULL,
    order_id INT NOT NULL,
    user_id INT NOT NULL,
    status_id INT NOT NULL, -- References our global order status table
    order_amount NUMERIC(10,2) NOT NULL,
    PRIMARY KEY (company_id, order_id)
);

-- 3. Create Global Order Reference Table
CREATE TABLE order_statuses (
    status_id INT PRIMARY KEY,
    status_name TEXT NOT NULL
);

-- 4. Create Tenant-Scoped Users Table
CREATE TABLE users (
    company_id INT NOT NULL,
    user_id INT NOT NULL,
    username TEXT NOT NULL,
    PRIMARY KEY (company_id, user_id)
);

-- 5. Create Tenant-Scoped Products Table
CREATE TABLE products (
    company_id INT NOT NULL,
    product_id INT NOT NULL,
    product_name TEXT NOT NULL,
    price NUMERIC(10,2) NOT NULL,
    PRIMARY KEY (company_id, product_id)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;telling-citus-how-to-shard-and-replicate&quot;&gt;Telling Citus How to Shard and Replicate&lt;/h3&gt;
&lt;p&gt;Now that we have created our core tables, we have to tell Citus how to shard and replicate them across our worker nodes.&lt;/p&gt;
&lt;h4 id=&quot;reference-tables&quot;&gt;Reference Tables&lt;/h4&gt;
&lt;p&gt;First, we will set &lt;code&gt;order_statuses&lt;/code&gt; as a reference table. We do this using &lt;code&gt;create_reference_table&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Why do we create a reference table? A reference table is designed for relatively small, slowly-changing data like user roles, country codes, or order statuses.&lt;/p&gt;
&lt;p&gt;When you mark a table as a reference table, Citus physically replicates a full copy of that entire table to every single worker node. This bypasses network overhead completely during JOIN operations. Because the worker node already has the data locally, it does not need to fetch it from another node to complete a query.&lt;/p&gt;
&lt;h4 id=&quot;distributed-tables&quot;&gt;Distributed Tables&lt;/h4&gt;
&lt;p&gt;Next, we need to distribute our large tenant-data tables. We do this using &lt;code&gt;create_distributed_table&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This function is the core of Citus's sharding mechanism. It tells the coordinator to partition the table based on a specific column (our shard key, &lt;code&gt;company_id&lt;/code&gt;), and spread those partitions across the available worker nodes.&lt;/p&gt;
&lt;p&gt;Because we use &lt;code&gt;company_id&lt;/code&gt; across all these tables, any query filtering by a specific company will be routed directly to a single node, making it incredibly fast.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Replicate order_statuses to ALL nodes
SELECT create_reference_table('order_statuses');

-- Shard data based on the Hash of company_id
SELECT create_distributed_table('companies', 'company_id');
SELECT create_distributed_table('users', 'company_id');
SELECT create_distributed_table('products', 'company_id');
SELECT create_distributed_table('orders', 'company_id');
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;multi-tenant-seed-data&quot;&gt;Multi-Tenant Seed Data&lt;/h3&gt;
&lt;p&gt;Now we will populate our cluster with some dummy companies along with some global order status entries, orders, and tenant users.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Global Order Lookups
INSERT INTO order_statuses VALUES (1, 'Pending'), (2, 'Shipped'), (3, 'Delivered');

-- Company Tenants
INSERT INTO companies VALUES (1, 'EbaMart'), (2, 'FufuHub'), (3, 'GymGear');

-- Multi-Tenant Users scattered across companies
INSERT INTO users (company_id, user_id, username) VALUES
(1, 101, 'Samuel'),   -- EbaMart Customer
(2, 201, 'Folashade'),-- FufuHub Customer
(1, 102, 'Umoh'),     -- EbaMart Customer
(3, 301, 'Oladejo'),  -- GymGear Customer
(2, 202, 'BigSam');   -- FufuHub Customer

-- Orders
INSERT INTO orders (company_id, order_id, user_id, status_id, order_amount) VALUES
(1, 5001, 101, 1, 150.00),  -- EbaMart order: Pending
(1, 5002, 102, 3, 45.00),   -- EbaMart order: Delivered
(2, 6001, 201, 2, 99.00),   -- FufuHub order: Shipped
(2, 6002, 202, 1, 250.00);  -- FufuHub order: Pending
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;verify-layout-and-node-placement&quot;&gt;Verify Layout and Node Placement&lt;/h3&gt;
&lt;p&gt;We can now verify the layout and node placement to see where they are added to and display it by running:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    u.company_id,
    u.user_id,
    u.username,
    s.shardid,
    s.nodename
FROM users u
JOIN citus_shards s
  ON s.shardid = get_shard_id_for_distribution_column('users', u.company_id)
ORDER BY u.company_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Output:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;company_id &lt;/th&gt;&lt;th&gt;user_id &lt;/th&gt;&lt;th&gt;username  &lt;/th&gt;&lt;th&gt;shardid &lt;/th&gt;&lt;th&gt;nodename&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1          &lt;/td&gt;&lt;td&gt;101     &lt;/td&gt;&lt;td&gt;Samuel    &lt;/td&gt;&lt;td&gt;102042  &lt;/td&gt;&lt;td&gt;db-sharding-worker-2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1          &lt;/td&gt;&lt;td&gt;102     &lt;/td&gt;&lt;td&gt;Umoh      &lt;/td&gt;&lt;td&gt;102042  &lt;/td&gt;&lt;td&gt;db-sharding-worker-2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2          &lt;/td&gt;&lt;td&gt;201     &lt;/td&gt;&lt;td&gt;Folashade &lt;/td&gt;&lt;td&gt;102065  &lt;/td&gt;&lt;td&gt;db-sharding-worker-1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2          &lt;/td&gt;&lt;td&gt;202     &lt;/td&gt;&lt;td&gt;BigSam    &lt;/td&gt;&lt;td&gt;102065  &lt;/td&gt;&lt;td&gt;db-sharding-worker-1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3          &lt;/td&gt;&lt;td&gt;301     &lt;/td&gt;&lt;td&gt;Oladejo   &lt;/td&gt;&lt;td&gt;102056  &lt;/td&gt;&lt;td&gt;db-sharding-worker-1&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;As you can see, all data for &lt;code&gt;company_id = 1&lt;/code&gt; sits neatly on &lt;code&gt;db-sharding-worker-2&lt;/code&gt;, while &lt;code&gt;company_id = 2&lt;/code&gt; sits on &lt;code&gt;db-sharding-worker-1&lt;/code&gt;. This guarantees that queries for a single tenant will only hit one node.&lt;/p&gt;
&lt;p&gt;We can also inspect the query plan for joining distributed tables with reference tables:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;EXPLAIN (VERBOSE, COSTS OFF)
SELECT o.order_id, o.order_amount, s.status_name
FROM orders o
JOIN order_statuses s ON o.status_id = s.status_id
WHERE o.company_id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Output:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                                      QUERY PLAN
---------------------------------------------------------------------
---------------------------------------------------------------------
---------------------------------------------------------------------
------------------------
 Custom Scan (Citus Adaptive)
   Output: remote_scan.order_id, remote_scan.order_amount, remote_scan.status_name
   Task Count: 1
   Tasks Shown: All
   -&amp;gt;  Task
         Query: SELECT o.order_id, o.order_amount, s.status_name FROM (public.orders_102106 o JOIN public.order_statuses_102008 s ON ((o.status_id OPERATOR(pg_catalog.=) s.status_id))) WHERE (o.company_id OPERATOR(pg_catalog.=) 1)
         Node: host=db-sharding-worker-2 port=5432 dbname=postgres
         -&amp;gt;  Hash Join
               Output: o.order_id, o.order_amount, s.status_name
               Hash Cond: (s.status_id = o.status_id)
               -&amp;gt;  Seq Scan on public.order_statuses_102008 s
                     Output: s.status_id, s.status_name
               -&amp;gt;  Hash
                     Output: o.order_id, o.order_amount, o.status_id
                     -&amp;gt;  Bitmap Heap Scan on public.orders_102106 o
                           Output: o.order_id, o.order_amount, o.status_id
                           Recheck Cond: (o.company_id = 1)
                           -&amp;gt;  Bitmap Index Scan on orders_pkey_102106
                                 Index Cond: (o.company_id = 1)
(19 rows)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The query plan shows a &lt;code&gt;Custom Scan (Citus Adaptive)&lt;/code&gt; delegating the query to the specific worker node holding &lt;code&gt;company_id = 1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;We can check the active worker nodes with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT * FROM master_get_active_worker_nodes();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Output:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;node_name            &lt;/th&gt;&lt;th&gt;node_port&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;db-sharding-worker-3 &lt;/td&gt;&lt;td&gt;5432&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;db-sharding-worker-1 &lt;/td&gt;&lt;td&gt;5432&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;db-sharding-worker-2 &lt;/td&gt;&lt;td&gt;5432&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;rebalancing-the-cluster&quot;&gt;Rebalancing the Cluster&lt;/h3&gt;
&lt;p&gt;Anytime you dynamically scale the cluster and register a new worker node, you must run the Citus Shard Rebalancer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT citus_rebalance_start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When new nodes are added, the coordinator does not automatically move existing data onto them. We have to explicitly balance the load. The rebalancer handles this by dynamically moving a subset of your tenants' data over to the new worker servers in the background, without taking the database offline.&lt;/p&gt;
&lt;h3 id=&quot;production-setup&quot;&gt;Production Setup&lt;/h3&gt;
&lt;p&gt;The Docker Compose setup is perfect for local testing and development. However, running a distributed database in production across multiple physical servers or virtual machines requires a slightly different approach.&lt;/p&gt;
&lt;p&gt;We must first change how network addressing and orchestration work. In a local setup, Docker Compose handles networking automatically because everything shares a single machine loopback. On production VMs, each container must be bound to the public or private IP address of its physical host machine, and you must enforce strict firewall security.&lt;/p&gt;
&lt;h4 id=&quot;1-spin-up-your-vms&quot;&gt;1. Spin up your VMs&lt;/h4&gt;
&lt;p&gt;Spin up your virtual machines on your preferred cloud provider. Assuming we provision 4 VMs, our network layout will look something like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;VM 1 (Coordinator Node)&lt;/strong&gt;: Private IP 10.0.0.10&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VM 2 (Worker Node 1)&lt;/strong&gt;: Private IP 10.0.0.11&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VM 3 (Worker Node 2)&lt;/strong&gt;: Private IP 10.0.0.12&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VM 4 (Worker Node 3)&lt;/strong&gt;: Private IP 10.0.0.13&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I used random private IPs, but you should substitute them with your real VMs' private IPs.&lt;/p&gt;
&lt;h4 id=&quot;2-install-docker&quot;&gt;2. Install Docker&lt;/h4&gt;
&lt;p&gt;Next, you will need to install Docker and Docker Compose on every VM. The installation commands will depend on your specific Linux distribution, so refer to the official Docker documentation for your OS.&lt;/p&gt;
&lt;p&gt;You will no longer use a single master compose file for the cluster. Instead, you deploy an isolated, optimized compose layout directly on each corresponding VM instance.&lt;/p&gt;
&lt;h4 id=&quot;3-the-coordinator-instance-vm-1-&quot;&gt;3. The Coordinator Instance (VM 1)&lt;/h4&gt;
&lt;p&gt;Create a &lt;code&gt;docker-compose.yml&lt;/code&gt; file specifically tailored to map the Postgres engine port directly out to the host VM network interface:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  master:
    container_name: &quot;citus_coordinator&quot;
    image: &quot;citusdata/citus:14.1.0&quot;
    ports:
      - &quot;5432:5432&quot;
    restart: always
    environment:
      POSTGRES_USER: &quot;citus_admin&quot;
      POSTGRES_PASSWORD: &quot;YOUR_SUPER_SECURE_PRODUCTION_PASSWORD&quot;
      PGUSER: &quot;citus_admin&quot;
      PGPASSWORD: &quot;YOUR_SUPER_SECURE_PRODUCTION_PASSWORD&quot;
      POSTGRES_HOST_AUTH_METHOD: &quot;scram-sha-256&quot;
    volumes:
      - coordinator_data:/var/lib/postgresql/data

volumes:
  coordinator_data:
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;4-the-worker-instances-vms-2-3-and-4-&quot;&gt;4. The Worker Instances (VMs 2, 3, and 4)&lt;/h4&gt;
&lt;p&gt;Every worker VM gets its own standalone compose file. Notice we expose port 5432 on the workers too, because the coordinator node needs a direct network pipe to talk to them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  worker:
    container_name: &quot;citus_worker&quot;
    image: &quot;citusdata/citus:14.1.0&quot;
    ports:
      - &quot;5432:5432&quot;
    restart: always
    environment:
      POSTGRES_USER: &quot;citus_admin&quot;
      POSTGRES_PASSWORD: &quot;YOUR_SUPER_SECURE_PRODUCTION_PASSWORD&quot;
      PGUSER: &quot;citus_admin&quot;
      PGPASSWORD: &quot;YOUR_SUPER_SECURE_PRODUCTION_PASSWORD&quot;
      POSTGRES_HOST_AUTH_METHOD: &quot;scram-sha-256&quot;
    volumes:
      - worker_data:/var/lib/postgresql/data

volumes:
  worker_data:
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;5-configure-network-firewall-security&quot;&gt;5. Configure Network Firewall Security&lt;/h4&gt;
&lt;p&gt;Since you are running across independent cloud instances, your cloud platform's Security Groups or Firewalls (like AWS Security Groups or ufw on Ubuntu) must be strictly configured to prevent public database scanning.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;On the Coordinator VM (VM 1)&lt;/strong&gt;: Allow inbound TCP traffic on port 5432 from Anywhere (0.0.0.0/0) only if your external application servers need public access. If your application servers are in the same VPC, restrict inbound port 5432 access exclusively to your app servers' IP subnet range.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On the Worker VMs (VMs 2, 3, 4)&lt;/strong&gt;: Allow inbound TCP traffic on port 5432 EXCLUSIVELY from the Coordinator's Private IP (10.0.0.10). Block all other inbound network requests to the workers. They should never be exposed to the internet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Next, log in to each VM and run the containers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once all containers are healthy, access the interactive postgres interface on VM 1 (the coordinator node):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose exec master psql -U citus_admin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register your external worker instances using their physical Private VPC IP addresses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT citus_add_node('10.0.0.11', 5432); -- Register Worker VM 2
SELECT citus_add_node('10.0.0.12', 5432); -- Register Worker VM 3
SELECT citus_add_node('10.0.0.13', 5432); -- Register Worker VM 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify everything works:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT * FROM master_get_active_worker_nodes();
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;6-configure-production-password-authentication&quot;&gt;6. Configure Production Password Authentication&lt;/h4&gt;
&lt;p&gt;Because we updated &lt;code&gt;POSTGRES_HOST_AUTH_METHOD&lt;/code&gt; to &lt;code&gt;scram-sha-256&lt;/code&gt; for security, your instances will require passwords to authenticate cross-node.&lt;/p&gt;
&lt;p&gt;We must add a Postgres password passfile on the coordinator container:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose exec master bash -c &quot;echo '*:*:*:citus_admin:YOUR_SUPER_SECURE_PRODUCTION_PASSWORD' &amp;gt;&amp;gt; /var/lib/postgresql/.pgpass &amp;amp;&amp;amp; chmod 600 /var/lib/postgresql/.pgpass&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Surely you will know to replace &lt;code&gt;YOUR_SUPER_SECURE_PRODUCTION_PASSWORD&lt;/code&gt; with your actual password.&lt;/p&gt;
&lt;p&gt;Here is a detailed documentation of Citus multi-node installation and setup for reference: &lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/postgresql/citus/multi-node-ubuntu-debian?view=citus-14&quot;&gt;Citus Multi-Node Setup&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;adding-citus-to-an-existing-project&quot;&gt;Adding Citus to an Existing Project&lt;/h3&gt;
&lt;p&gt;To add Citus to an already existing Postgres database project, the process follows these general steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Install the Extension&lt;/strong&gt;: Install the Citus extension on your Postgres server. You will also need to add &lt;code&gt;citus&lt;/code&gt; to the &lt;code&gt;shared_preload_libraries&lt;/code&gt; in your &lt;code&gt;postgresql.conf&lt;/code&gt; file and restart the database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Create the Extension&lt;/strong&gt;: Run &lt;code&gt;CREATE EXTENSION citus;&lt;/code&gt; in your database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Select a Shard Key&lt;/strong&gt;: Identify the column that will serve as the shard key for your distributed tables, such as a company or tenant ID.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Convert Tables&lt;/strong&gt;: Use &lt;code&gt;create_distributed_table()&lt;/code&gt; to convert your existing tables into distributed tables. Use &lt;code&gt;create_reference_table()&lt;/code&gt; for lookup tables that do not need to be sharded.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate Data&lt;/strong&gt;: If you already have data in the tables, it might need to be exported and loaded back in, depending on the volume of data and the specific requirements of the migration.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;conclusion-3&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Database sharding is an incredibly powerful architectural pattern, but it comes with immense operational tradeoffs. By using tools like Citus, we can bridge the gap between standard relational SQL databases and massively distributed systems.&lt;/p&gt;
&lt;p&gt;Whether you are starting a new multi-tenant application or scaling out an existing infrastructure, adopting a distributed mindset and orchestrating your local environments with tools like Citus and Docker Compose will save you countless hours of architectural nightmares down the road.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You can find the full Docker Compose source code for this tutorial on GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/umohsamuel/database-sharding&quot;&gt;umohsamuel/database-sharding&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;references&quot;&gt;References&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cloud.google.com/discover/what-is-database-sharding&quot;&gt;What is Database Sharding? (Google Cloud)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/what-is/database-sharding/&quot;&gt;What is Database Sharding? (AWS)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/citusdata/docker/blob/master/docker-compose.yml&quot;&gt;Citus Docker Compose Setup&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28711/horizontally-scaling-postgresql-database-sharding-with-citus</guid>
<pubDate>Sat, 26 Sep 2026 10:35:35 +0000</pubDate>
</item>
<item>
<title>Data Engineering Beyond &quot;Pretty Charts&quot;: How I Built an Aviation Telemetry Leakage Engine</title>
<link>https://coderlegion.com/28338/data-engineering-beyond-pretty-charts-how-i-built-an-aviation-telemetry-leakage-engine</link>
<description>&lt;h2 id=&quot;stop-tracking-on-time-flights-track-tarmac-fuel-destruction-instead-&quot;&gt;Stop Tracking &quot;On-Time&quot; Flights. Track Tarmac Fuel Destruction Instead.&lt;/h2&gt;
&lt;p&gt;Most aviation dashboards track gate-to-gate &quot;On-Time&quot; arrivals. The problem? &lt;strong&gt;It's a lagging indicator that completely masks why airlines silently hemorrhage millions.&lt;/strong&gt; Padded flight schedules disguise a massive bottleneck: chronic tarmac fuel destruction occurring right before takeoff.&lt;/p&gt;
&lt;p&gt;I built &lt;strong&gt;AeroOps&lt;/strong&gt;, an end-to-end telemetry and multi-domain operations leakage engine, to expose exactly where systems drop value between the hangar and the runway.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; Repo &amp;amp; Technical Report:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hafsamotiwala/aero-ops-forensic-audit&quot;&gt;[GitHub Link]&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt; Stack:&lt;/strong&gt; MySQL (Star Schema &amp;amp; CTEs) · Telemetry Data Modeling · Executive Dashboard Architecture · Power BI (DAX)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-architectural-amp-engineering-challenge&quot;&gt;The Architectural &amp;amp; Engineering Challenge&lt;/h4&gt;
&lt;p&gt;Handling flight telemetry means dealing with high-volume, disjointed datasets. To make this actionable, the engineering required:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Star Schema Data Warehousing:&lt;/strong&gt; Joined &lt;strong&gt;513,000+ flight legs&lt;/strong&gt; with precise technical burn parameters pulled directly from the &lt;em&gt;ICAO Aircraft Emissions Databank&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bypassing Relational Boundaries:&lt;/strong&gt; Formulated deduplicated DAX lookup models to handle wide-body propulsion penalties under sub-second memory constraints.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Algorithmic Baseline Filters:&lt;/strong&gt; Enforced a strict &lt;strong&gt;4-minute engine warm-up buffer&lt;/strong&gt; directly into the SQL logic. This stripped away safe, expected operational windows to isolate true, excess ground latency.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-bottom-line-impact-metrics&quot;&gt;The Bottom-Line Impact Metrics&lt;/h4&gt;
&lt;p&gt;By querying raw transaction logs and user event data instead of building passive, &quot;pretty&quot; charts, the engine surfaced massive operational friction:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; $42.69M in Tarmac Cash Destruction:&lt;/strong&gt; Isolated and quantified the exact cost of ground traffic gridlocks at major hubs like Atlanta (ATL).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt; 54% to 57% Short-Haul Menu Spoilage:&lt;/strong&gt; Exposed a massive waste loop across major carriers (Delta/American) due to over-boarding. Transitioning the system to a 100% shelf-stable inventory model &lt;strong&gt;reclaimed $2.58M in simulated margin leaks&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt; $725K Legal Fine Exposure:&lt;/strong&gt; Isolated a hidden &lt;strong&gt;9% telemetry sync drop rate&lt;/strong&gt; specifically among regional commuter airframes caused by corrupted logs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt; Prevention of $1.50M Asset Groundings (AOG):&lt;/strong&gt; Coded an automated Reorder Point (ROP) trigger tool to insulate warehouses against catastrophic out-of-stock asset scenarios.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-takeaway-1&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;Data shouldn't just look nice in a presentation deck. If your queries aren't actively protecting business margins or exposing systemic infrastructure leaks, you're looking at the wrong metrics. &lt;/p&gt;
&lt;p&gt;If your team is scaling fast and dealing with messy relational pipelines or unoptimized telemetry data, let's connect and fix your backend infrastructure loops. &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28338/data-engineering-beyond-pretty-charts-how-i-built-an-aviation-telemetry-leakage-engine</guid>
<pubDate>Wed, 23 Sep 2026 12:10:54 +0000</pubDate>
</item>
<item>
<title>Reverse-Engineering Messy Databases: My End-to-End Data Audits Across 17,000+ Relational Schema Logs</title>
<link>https://coderlegion.com/28337/reverse-engineering-messy-databases-end-end-data-audits-across-000-relational-schema-logs</link>
<description>&lt;h3 id=&quot;from-raw-logs-to-business-strategy-how-i-taught-myself-data-engineering-amp-analytics&quot;&gt;From Raw Logs to Business Strategy: How I Taught Myself Data Engineering &amp;amp; Analytics&lt;/h3&gt;
&lt;p&gt;I didn’t take the traditional path into data. Over the past 12 months, I bypassed the university lecture halls to teach myself relational database architecture, pipeline optimization, and growth analytics by diving straight into raw datasets.&lt;/p&gt;
&lt;p&gt;I look at data through an engineering and business lens. Instead of building passive charts that just look pretty, I query transaction logs and user event data to find hidden operational friction, map out user behavior patterns, and isolate exactly where systems are dropping value. I build end-to-end data systems: raw SQL schemas, data sanitization scripts, interactive Power BI dashboards, and single-page executive reports.&lt;/p&gt;
&lt;p&gt;Below are the architectural breakdowns, technical challenges, and business impact metrics of the four core data infrastructure projects I have engineered from scratch.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;project-1-stockpulse-multi-regional-supply-chain-audit&quot;&gt;Project 1: StockPulse — Multi-Regional Supply Chain Audit&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Technical Stack:&lt;/strong&gt; MySQL (Mathematical Window Functions), Power BI, Advanced Excel&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Codebase &amp;amp; Dashboard Layout:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hafsamotiwala/Enterprise-Procurement-Supply-Chain-Resilience-Pipeline&quot;&gt;[LINK]&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-operational-bottleneck&quot;&gt;The Operational Bottleneck&lt;/h4&gt;
&lt;p&gt;Horizon Marketplace faced shrinking profit margins from slow-moving warehouse stock, while simultaneous supplier shipping delays caused high-demand items to go out of stock on our mobile application. Traditional procurement tracking models failed because they relied on simple historical averages, which immediately miscalculate when data hits high seasonal sales volatility spikes.&lt;/p&gt;
&lt;h4 id=&quot;the-technical-implementation&quot;&gt;The Technical Implementation&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Audited 17,646 operational and sales ledger transactions&lt;/strong&gt; using MySQL window functions to calculate cumulative revenue contributions per SKU.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Coded a predictive restocking matrix script&lt;/strong&gt; utilizing sales volatility standard deviations and rolling manufacturer lead timelines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Formulated dynamic Safety Stock buffers&lt;/strong&gt; and programmed automated database Reorder Point triggers to flag supplier order alerts before shelves hit zero.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-business-impact-metrics&quot;&gt;The Business Impact Metrics&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ABC inventory segmentation&lt;/strong&gt; proved that exactly 23 Class A items drive 80% of total marketplace revenue, showing that the remaining catalog space was tying up critical capital.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quantified a hidden revenue bleed of $63,897.26&lt;/strong&gt; caused by a single high-demand apparel product sitting entirely out of stock for 42 days due to upstream logistics bottlenecks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transitioned a multi-regional operation&lt;/strong&gt; from manual, time-intensive spreadsheets to an automated, predictive data framework.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;project-2-funnelopt-habitarcade-b2c-telemetry-audit&quot;&gt;Project 2: FunnelOpt — HabitArcade B2C Telemetry Audit&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Technical Stack:&lt;/strong&gt; MySQL (Session Overrides), Funnel Analysis, User Lifecycle Mapping&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Codebase &amp;amp; Database Logs:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hafsamotiwala/-HabitArcade-Enterprise-B2C-SaaS-Funnel-Telemetry-Analysis&quot;&gt;[LINK]&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-operational-bottleneck-1&quot;&gt;The Operational Bottleneck&lt;/h4&gt;
&lt;p&gt;A B2C application tracking engine suffered from severe data execution drops where product behavioral logs completely failed to map user activation patterns. The relational schema dropped child telemetry records because they were importing prior to the absolute database finalization of the parent account index logs.&lt;/p&gt;
&lt;h4 id=&quot;the-technical-implementation-1&quot;&gt;The Technical Implementation&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Audited full-funnel growth logs&lt;/strong&gt; across 6 independent acquisition pipelines to analyze customer conversion latency and drop-offs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured specific MySQL session overrides&lt;/strong&gt; to temporarily bypass structural foreign key constraints.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forced clean data synchronization&lt;/strong&gt; across child records to completely restore parent-child index synchronization without corrupting historical system logs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-business-impact-metrics-1&quot;&gt;The Business Impact Metrics&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Isolated a systemic “Streak-Breaking Crash Tax”&lt;/strong&gt; where application sync errors wiped 30-day user milestones, triggering immediate 1-star reviews and an active 6% to 7% monthly subscriber bleed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exposed an “Inverted Value Curve”&lt;/strong&gt; revealing that high-value annual checkout tiers underperformed monthly subscriptions due to silent gateway billing processor failures blocking top-priced subscription cash settlement.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;project-3-leakstop-olist-b2b-revenue-leakage-amp-onboarding-audit&quot;&gt;Project 3: LeakStop — Olist B2B Revenue Leakage &amp;amp; Onboarding Audit&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Technical Stack:&lt;/strong&gt; MySQL (Schema Engineering), Power BI, Business Intelligence&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Codebase &amp;amp; Analytical Readme:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hafsamotiwala/B2B-Saas-Funnel-Optimization-Audit&quot;&gt;[LINK]&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-operational-bottleneck-2&quot;&gt;The Operational Bottleneck&lt;/h4&gt;
&lt;p&gt;Cross-vendor data duplication across digital marketplace ledgers was heavily skewing gross reporting metrics. Standard relational database joins double-counted total transactions when a customer order spanned multiple independent business vendors in a single checkout loop.&lt;/p&gt;
&lt;h4 id=&quot;the-technical-implementation-2&quot;&gt;The Technical Implementation&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Engineered robust MySQL ingestion and data sanitization scripts&lt;/strong&gt; to enforce strict primary key integrity down to individual item vectors, removing all reporting bias.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Processed user account lifecycles&lt;/strong&gt; to calculate onboarding completion latency across specific B2B verticals.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Programmed a real-time database filter&lt;/strong&gt; to flag slipping user accounts precisely on Day 16 of transaction inactivity.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-business-impact-metrics-2&quot;&gt;The Business Impact Metrics&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Isolated an annual revenue leakage of R$ 600,600&lt;/strong&gt; (approximately $119,175 USD) stemming from 462 merchant accounts that stalled out completely during the onboarding phase.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exposed an invisible latency anomaly&lt;/strong&gt; where logistically complex business profiles stalled for an average of 112 days in domain routing and tax mapping phases.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Implemented a Day 16 early-warning database filter&lt;/strong&gt; to flag at-risk accounts before they reached a terminal, zero-recovery retention cliff.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;project-4-cohortpace-saas-behavioral-retention-engine&quot;&gt;Project 4: CohortPace — SaaS Behavioral Retention Engine&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Technical Stack:&lt;/strong&gt; MySQL (Subqueries &amp;amp; Relational Joins), Cohort Modeling, Data Analytics&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Codebase &amp;amp; Technical Report:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hafsamotiwala/SaaS-User-Behavior-Analysis-Email-Creator-Platforms-&quot;&gt;[LINK]&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-operational-bottleneck-3&quot;&gt;The Operational Bottleneck&lt;/h4&gt;
&lt;p&gt;Initial application dashboards focused blindly on feature exploration depth, which completely masked why healthy-looking active user profiles were suddenly canceling subscriptions. Traditional active-user metrics acted as a lagging indicator, hiding friction because churning accounts remain active inside broken workflows right up to their exit timelines.&lt;/p&gt;
&lt;h4 id=&quot;the-technical-implementation-3&quot;&gt;The Technical Implementation&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Modeled simulated user cohort transactions&lt;/strong&gt; across multi-platform SaaS behavioral profiles to analyze the core drivers of long-term Monthly Recurring Revenue (MRR).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Used advanced MySQL subqueries and relational joins&lt;/strong&gt; to reconstruct full lifecycle interaction sequences.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mapped user interaction velocity trends&lt;/strong&gt; against subscription lifecycles to isolate clear behavioral churn signatures.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-business-impact-metrics-3&quot;&gt;The Business Impact Metrics&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Proved through data pacing&lt;/strong&gt; sending signals that churning users maintain consistent interaction velocity close to their exit date, showing churn is driven by late-stage workflow utility limits rather than gradual disengagement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Demonstrated that long-term user monetization&lt;/strong&gt; is strictly dependent on habit formation within a single core workflow rather than broad, multi-feature platform exploration.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion-turning-code-into-business-strategy&quot;&gt;Conclusion: Turning Code Into Business Strategy&lt;/h3&gt;
&lt;p&gt;I don’t need a playbook to get started, and I don’t need a manager to hand me a static checklist. I find the question, ingest the data, clean the schemas, model the answer, and present the metric that directly protects business margins.&lt;/p&gt;
&lt;p&gt;I am actively available for analytical remote contract roles, standalone data audits, and freelance pipeline optimization. If your startup is scaling fast and dealing with messy relational pipelines, let’s connect and fix your backend infrastructure loops.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; Interactive Portfolio Hub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://lucky-bit-036.notion.site/HAFSA-5fd489cedd70459ca0237c36a168f30a&quot;&gt;[LINK]&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt; Contact:&lt;/strong&gt; motiwala603@gmail.com&lt;/li&gt;
&lt;li&gt;&lt;strong&gt; Connect with me:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/hafsa-motiwala-a9608835b/&quot;&gt;[LinkedIn]&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Open to analyst remote roles across product, growth, and business analytics. Based in Pakistan, available worldwide.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28337/reverse-engineering-messy-databases-end-end-data-audits-across-000-relational-schema-logs</guid>
<pubDate>Wed, 23 Sep 2026 11:56:39 +0000</pubDate>
</item>
<item>
<title>Personal Expense Tracker V2 — Edit, Validation &amp; Error Handling</title>
<link>https://coderlegion.com/28205/personal-expense-tracker-v2-edit-validation-error-handling</link>
<description>&lt;p&gt;I’ve completed &lt;strong&gt;V2&lt;/strong&gt; of my Personal Expense Tracker &lt;/p&gt;
&lt;p&gt;This project started as a way to practice &lt;strong&gt;Python OOP, SQL, and SQLite&lt;/strong&gt; by building something practical instead of only studying theory.&lt;/p&gt;
&lt;p&gt;V1 focused on getting the core CRUD operations working.&lt;/p&gt;
&lt;p&gt;With V2, I focused on making the application more reliable and user-friendly.&lt;/p&gt;
&lt;h3 id=&quot;what-i-added-in-v2&quot;&gt;What I Added in V2&lt;/h3&gt;
&lt;h4 id=&quot;-edit-expenses&quot;&gt;✏️ Edit Expenses&lt;/h4&gt;
&lt;p&gt;Users can now modify an existing expense.&lt;/p&gt;
&lt;p&gt;The edit flow allows changing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Amount&lt;/li&gt;
&lt;li&gt;Category&lt;/li&gt;
&lt;li&gt;Description&lt;/li&gt;
&lt;li&gt;Date&lt;/li&gt;
&lt;li&gt;Cancel the operation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This also gave me more practical experience working with SQL &lt;code&gt;UPDATE&lt;/code&gt; statements and designing application flow around user choices.&lt;/p&gt;
&lt;h4 id=&quot;-input-validation&quot;&gt;️ Input Validation&lt;/h4&gt;
&lt;p&gt;I also added validation for user input.&lt;/p&gt;
&lt;p&gt;The application now handles things such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Invalid menu choices&lt;/li&gt;
&lt;li&gt;Invalid amount input&lt;/li&gt;
&lt;li&gt;Negative or zero amounts&lt;/li&gt;
&lt;li&gt;Invalid category selection&lt;/li&gt;
&lt;li&gt;Empty descriptions&lt;/li&gt;
&lt;li&gt;Invalid dates&lt;/li&gt;
&lt;li&gt;Impossible dates such as &lt;code&gt;2026-02-30&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, instead of allowing invalid input to crash the program, the application asks the user to provide a valid value.&lt;/p&gt;
&lt;h4 id=&quot;-error-handling&quot;&gt;⚠️ Error Handling&lt;/h4&gt;
&lt;p&gt;While implementing validation, I also started thinking more carefully about where errors can happen and how the application should respond.&lt;/p&gt;
&lt;p&gt;One important lesson was that &lt;strong&gt;input validation is not just about preventing crashes&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It is about defining what the application considers valid input and controlling the flow when the user provides something unexpected.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;/h3&gt;
&lt;p&gt;V2 taught me more than just how to add an edit feature.&lt;/p&gt;
&lt;p&gt;I learned about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Designing validation rules before implementing them&lt;/li&gt;
&lt;li&gt;Using &lt;code&gt;try/except&lt;/code&gt; for user input&lt;/li&gt;
&lt;li&gt;Separating application flow from business logic&lt;/li&gt;
&lt;li&gt;Handling different types of invalid input&lt;/li&gt;
&lt;li&gt;Thinking about edge cases&lt;/li&gt;
&lt;li&gt;Designing better CLI user interaction&lt;/li&gt;
&lt;li&gt;Keeping the project scope under control&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One thing I learned during this version is that &lt;strong&gt;not every possible improvement needs to be implemented immediately&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;There are still things I could add, such as more advanced database error handling and additional checks, but I decided to stop V2 at the scope I originally defined.&lt;/p&gt;
&lt;p&gt;That is also part of learning software development: knowing when a version is complete.&lt;/p&gt;
&lt;h3 id=&quot;-current-architecture&quot;&gt;️ Current Architecture&lt;/h3&gt;
&lt;p&gt;The project currently follows a simple separation of responsibilities:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;main.py
   ↓
ExpenseManager
   ↓
Database / storage.py
   ↓
SQLite
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; handles the CLI/application flow, &lt;code&gt;ExpenseManager&lt;/code&gt; handles the application logic, and &lt;code&gt;storage.py&lt;/code&gt; handles database operations.&lt;/p&gt;
&lt;h3 id=&quot;v2-status&quot;&gt;V2 Status&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;V2 is now complete.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The project currently supports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ Add expenses&lt;/li&gt;
&lt;li&gt;✅ View expenses&lt;/li&gt;
&lt;li&gt;✅ Edit expenses&lt;/li&gt;
&lt;li&gt;✅ Delete expenses&lt;/li&gt;
&lt;li&gt;✅ Category selection&lt;/li&gt;
&lt;li&gt;✅ Input validation&lt;/li&gt;
&lt;li&gt;✅ Date validation&lt;/li&gt;
&lt;li&gt;✅ Basic error handling&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The main goal of this version was not to build a huge application.&lt;/p&gt;
&lt;p&gt;It was to take the working V1 and make it &lt;strong&gt;more reliable while strengthening my understanding of Python, OOP, SQL, SQLite, and application design.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;project&quot;&gt;Project&lt;/h3&gt;
&lt;p&gt; &lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/xDK0d3r/personal-expense-tracker&quot;&gt;Personal Expense Tracker&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I’ll continue improving the project in future versions when there is something meaningful to learn or add.&lt;/p&gt;
&lt;p&gt;Thanks for reading! &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28205/personal-expense-tracker-v2-edit-validation-error-handling</guid>
<pubDate>Tue, 22 Sep 2026 17:19:39 +0000</pubDate>
</item>
<item>
<title>Personal Expense Tracker — V1 Complete: Building the Core Working Stage</title>
<link>https://coderlegion.com/28203/personal-expense-tracker-v1-complete-building-the-core-working-stage</link>
<description>&lt;p&gt;I’ve completed &lt;strong&gt;V1 of my Personal Expense Tracker&lt;/strong&gt;, a small &lt;strong&gt;learning project&lt;/strong&gt; built with &lt;strong&gt;Python and SQLite&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This project was created primarily to learn by building rather than following a tutorial step by step.&lt;/p&gt;
&lt;p&gt;The goal of V1 was not to build a production-ready expense tracker.&lt;/p&gt;
&lt;p&gt;The goal was:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Build the core application flow, understand the responsibilities of each layer, and make the fundamental functionality work end-to-end.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;With that goal achieved, I consider &lt;strong&gt;V1 complete&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;why-i-built-this-project&quot;&gt;Why I Built This Project&lt;/h3&gt;
&lt;p&gt;I wanted to strengthen my understanding of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Python OOP&lt;/li&gt;
&lt;li&gt;Classes and objects&lt;/li&gt;
&lt;li&gt;Object responsibilities&lt;/li&gt;
&lt;li&gt;SQL and SQLite&lt;/li&gt;
&lt;li&gt;CRUD operations&lt;/li&gt;
&lt;li&gt;Database design&lt;/li&gt;
&lt;li&gt;Separating application responsibilities&lt;/li&gt;
&lt;li&gt;How different layers communicate with each other&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead of learning these concepts separately, I decided to combine them into one small practical project.&lt;/p&gt;
&lt;p&gt;That's why I consider this primarily a &lt;strong&gt;learning project&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The objective was not to create a feature-rich application immediately, but to understand the fundamentals by actually building something.&lt;/p&gt;
&lt;h3 id=&quot;what-i-built&quot;&gt;What I Built&lt;/h3&gt;
&lt;p&gt;The application is a command-line Personal Expense Tracker.&lt;/p&gt;
&lt;p&gt;The core V1 functionality includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Adding an expense&lt;/li&gt;
&lt;li&gt;Viewing expenses&lt;/li&gt;
&lt;li&gt;Deleting an expense&lt;/li&gt;
&lt;li&gt;Storing expenses in SQLite&lt;/li&gt;
&lt;li&gt;Using SQLite-generated IDs&lt;/li&gt;
&lt;li&gt;Separating the application into different responsibilities&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The database currently contains an &lt;code&gt;expenses&lt;/code&gt; table:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Column        &lt;/th&gt;&lt;th&gt;Type&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;id&lt;/code&gt;          &lt;/td&gt;&lt;td&gt;INTEGER PRIMARY KEY&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;amount&lt;/code&gt;      &lt;/td&gt;&lt;td&gt;REAL&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;category&lt;/code&gt;    &lt;/td&gt;&lt;td&gt;TEXT&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;description&lt;/code&gt; &lt;/td&gt;&lt;td&gt;TEXT&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;date&lt;/code&gt;        &lt;/td&gt;&lt;td&gt;TEXT&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;application-architecture&quot;&gt;Application Architecture&lt;/h3&gt;
&lt;p&gt;One of the main things I wanted to learn was how to organize responsibilities in an application.&lt;/p&gt;
&lt;p&gt;I structured the project into three main layers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;main.py
   ↓
ExpenseManager
   ↓
Database
   ↓
SQLite
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;main-py-ui-layer&quot;&gt;&lt;code&gt;main.py&lt;/code&gt; — UI Layer&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; is responsible for interacting with the user through the CLI.&lt;/p&gt;
&lt;p&gt;Its responsibilities include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Displaying the menu&lt;/li&gt;
&lt;li&gt;Taking user input&lt;/li&gt;
&lt;li&gt;Calling the appropriate operation&lt;/li&gt;
&lt;li&gt;Displaying results&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The UI does not directly handle SQL operations.&lt;/p&gt;
&lt;h4 id=&quot;expense-manager-py-application-layer&quot;&gt;&lt;code&gt;expense_manager.py&lt;/code&gt; — Application Layer&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;ExpenseManager&lt;/code&gt; acts as the middle layer between the UI and storage.&lt;/p&gt;
&lt;p&gt;It coordinates application operations and communicates with the database object.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;main.py
   ↓
ExpenseManager.add_expense()
   ↓
Database.insert_expense()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This helped me understand the difference between the application's operation and the actual persistence of data.&lt;/p&gt;
&lt;h4 id=&quot;storage-py-persistence-layer&quot;&gt;&lt;code&gt;storage.py&lt;/code&gt; — Persistence Layer&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;Database&lt;/code&gt; class handles communication with SQLite.&lt;/p&gt;
&lt;p&gt;Its responsibilities include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Creating the database connection&lt;/li&gt;
&lt;li&gt;Initializing the database&lt;/li&gt;
&lt;li&gt;Creating the table&lt;/li&gt;
&lt;li&gt;Inserting expenses&lt;/li&gt;
&lt;li&gt;Retrieving expenses&lt;/li&gt;
&lt;li&gt;Deleting expenses&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This keeps database-related responsibilities separate from the UI.&lt;/p&gt;
&lt;h3 id=&quot;understanding-object-relationships&quot;&gt;Understanding Object Relationships&lt;/h3&gt;
&lt;p&gt;Another important part of this project was understanding how objects can work together.&lt;/p&gt;
&lt;p&gt;The application creates a database object and passes it to &lt;code&gt;ExpenseManager&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Conceptually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;main.py
   │
   ├── creates Database
   │
   └── creates ExpenseManager
             │
             └── uses Database
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The manager receives the database dependency instead of creating its own database internally.&lt;/p&gt;
&lt;p&gt;This was one of the OOP concepts I wanted to understand through practical implementation.&lt;/p&gt;
&lt;h3 id=&quot;why-v1-is-complete&quot;&gt;Why V1 Is Complete&lt;/h3&gt;
&lt;p&gt;There is an important distinction between:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;V1 is complete&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;and&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;the project is finished.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;V1 is complete because its intended scope has been achieved.&lt;/p&gt;
&lt;p&gt;The core application flow works:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;User
 ↓
CLI
 ↓
ExpenseManager
 ↓
Database
 ↓
SQLite
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An expense can be added and stored, stored expenses can be retrieved and displayed, and expenses can be deleted.&lt;/p&gt;
&lt;p&gt;The different layers can communicate with each other correctly.&lt;/p&gt;
&lt;p&gt;That is the foundation I wanted to establish in V1.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned-from-v1&quot;&gt;What I Learned From V1&lt;/h3&gt;
&lt;p&gt;The biggest lesson wasn't simply learning SQLite syntax or Python syntax.&lt;/p&gt;
&lt;p&gt;It was understanding &lt;strong&gt;responsibility&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I learned to think about the application as separate parts with different jobs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;UI
→ communicates with the user

Manager
→ coordinates application operations

Storage
→ communicates with the database
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Along the way, I gained practical experience with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Python classes&lt;/li&gt;
&lt;li&gt;Constructors and attributes&lt;/li&gt;
&lt;li&gt;Methods&lt;/li&gt;
&lt;li&gt;Object relationships&lt;/li&gt;
&lt;li&gt;Passing dependencies between objects&lt;/li&gt;
&lt;li&gt;SQLite tables&lt;/li&gt;
&lt;li&gt;Primary keys&lt;/li&gt;
&lt;li&gt;CRUD operations&lt;/li&gt;
&lt;li&gt;SQL queries&lt;/li&gt;
&lt;li&gt;Multi-module Python projects&lt;/li&gt;
&lt;li&gt;Debugging&lt;/li&gt;
&lt;li&gt;Git and GitHub&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-i-didn-t-focus-on-in-v1&quot;&gt;What I Didn't Focus On in V1&lt;/h3&gt;
&lt;p&gt;I deliberately didn't try to make V1 handle every possible invalid input or edge case.&lt;/p&gt;
&lt;p&gt;Comprehensive validation and robust error handling were not the main goals of this version.&lt;/p&gt;
&lt;p&gt;Instead, I decided to make the &lt;strong&gt;core functionality work first&lt;/strong&gt; and move robustness-related improvements into V2.&lt;/p&gt;
&lt;p&gt;This gives the project a clearer progression:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;V1
Core Working Stage
       ↓
V2
Robustness + Additional Core Operation
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-s-coming-in-v2-&quot;&gt;What's Coming in V2?&lt;/h3&gt;
&lt;p&gt;Now that the core working stage is complete, V2 will focus on improving the application's robustness and functionality.&lt;/p&gt;
&lt;p&gt;The planned V2 work includes:&lt;/p&gt;
&lt;h4 id=&quot;validation&quot;&gt;Validation&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Validating user input&lt;/li&gt;
&lt;li&gt;Validating expense IDs&lt;/li&gt;
&lt;li&gt;Handling invalid values&lt;/li&gt;
&lt;li&gt;Handling invalid menu input&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;error-handling&quot;&gt;Error Handling&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Handling invalid user input gracefully&lt;/li&gt;
&lt;li&gt;Handling invalid/non-existent expense IDs&lt;/li&gt;
&lt;li&gt;Handling database-related errors&lt;/li&gt;
&lt;li&gt;Providing better user-facing error messages&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;edit-expense&quot;&gt;Edit Expense&lt;/h4&gt;
&lt;p&gt;V2 will also introduce an &lt;strong&gt;Edit Expense&lt;/strong&gt; operation.&lt;/p&gt;
&lt;p&gt;The core expense operations will then become:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Create
Read
Update
Delete
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or simply:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;CRUD
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will make the expense management functionality more complete.&lt;/p&gt;
&lt;h3 id=&quot;v1-v2&quot;&gt;V1 → V2&lt;/h3&gt;
&lt;p&gt;The project's progression can be summarized as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;V1
│
├── Add Expense
├── View Expenses
├── Delete Expense
├── SQLite persistence
├── Layered architecture
└── Core application flow
        │
        ▼
V2
│
├── Edit Expense
├── Input validation
├── ID validation
├── Error handling
└── CLI refinement
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I intentionally want to keep these stages separate.&lt;/p&gt;
&lt;p&gt;V1 proves that the &lt;strong&gt;foundation works&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;V2 will make that foundation &lt;strong&gt;more robust and complete&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;This project started as a learning exercise to understand Python OOP and SQLite by building something practical.&lt;/p&gt;
&lt;p&gt;Completing V1 gave me more than just another project for my GitHub repository.&lt;/p&gt;
&lt;p&gt;It helped me understand how a small application can be divided into responsibilities and how those components communicate with each other.&lt;/p&gt;
&lt;p&gt;I'm treating V1 as a &lt;strong&gt;completed learning milestone&lt;/strong&gt;, not as the final state of the application.&lt;/p&gt;
&lt;p&gt;The core working stage is done.&lt;/p&gt;
&lt;h3 id=&quot;project-1&quot;&gt;Project&lt;/h3&gt;
&lt;p&gt; &lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/xDK0d3r/personal-expense-tracker&quot;&gt;Personal Expense Tracker&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Now it's time to build on that foundation.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;V1: Complete.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Core working stage: Achieved.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Next: V2 — Validation, Error Handling &amp;amp; Edit Expense.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28203/personal-expense-tracker-v1-complete-building-the-core-working-stage</guid>
<pubDate>Tue, 22 Sep 2026 17:10:47 +0000</pubDate>
</item>
<item>
<title>The Silent Performance Killer: Demystifying the N+1 Query Problem in Node.js</title>
<link>https://coderlegion.com/27905/the-silent-performance-killer-demystifying-the-n-1-query-problem-in-node-js</link>
<description>&lt;p&gt;Have you ever noticed a feature that works beautifully on your local computer, only to crawl at a snail's pace once it gets deployed to production? One of the most common, yet invisible, reasons for this sudden slowdown is a database fetching behavior known as the N+1 query problem.&lt;/p&gt;
&lt;p&gt;In short, the N+1 query problem occurs when an application makes one initial database query to fetch a list of records (the &quot;1&quot;), and then makes a separate database query for every single record returned in that list (the &quot;N&quot;) to fetch related data. Instead of fetching all the required information in a single, efficient request, the application ends up chatting back and forth with the database dozens, hundreds, or thousands of times.&lt;/p&gt;
&lt;h4 id=&quot;the-restaurant-server-analogy&quot;&gt;The Restaurant Server Analogy&lt;/h4&gt;
&lt;p&gt;To understand why this is so inefficient, imagine sitting down at a busy restaurant with a table of ten friends. It is time to order drinks.&lt;/p&gt;
&lt;p&gt;An efficient server (who behaves like an optimized database query) would take everyone's drink orders on a notepad, walk to the kitchen once, load all ten glasses onto a single tray, and bring them back to the table in one trip. This requires just one round-trip to the kitchen.&lt;/p&gt;
&lt;p&gt;Now imagine an N+1 server. This server walks to the table, asks the first person what they want, and walks all the way back to the kitchen to fetch that single drink. After serving it, they walk back to the table, ask the second person for their order, and make another complete round-trip to the kitchen. They repeat this individual process for all ten people. By the time everyone has a drink, the server has made eleven total trips (1 trip to inspect the table, plus N trips for the N guests). Your dining experience would be incredibly slow, and the poor server would be completely exhausted.&lt;/p&gt;
&lt;h4 id=&quot;why-this-matters-in-daily-engineering&quot;&gt;Why This Matters in Daily Engineering&lt;/h4&gt;
&lt;p&gt;In real-world software engineering, database round-trips are remarkably expensive. Every single query you run introduces network latency, database parsing overhead, and thread consumption in your database's connection pool.&lt;/p&gt;
&lt;p&gt;If you are building a social media dashboard that displays 50 posts, and you load the comments for each post individually, you are executing 51 database queries. If 1,000 users visit that page simultaneously, your application will suddenly bombard your database with 51,000 queries. This easily exhausts your database connection pool, drives database CPU usage to 100%, and results in catastrophic timeouts for your users. Engineers must actively prevent this to keep application response times low and infrastructure costs manageable.&lt;/p&gt;
&lt;h4 id=&quot;seeing-it-in-action-node-js-and-mysql&quot;&gt;Seeing It in Action: Node.js and MySQL&lt;/h4&gt;
&lt;p&gt;Let's look at a typical Express.js endpoint where this mistake happens, followed by how we fix it.&lt;/p&gt;
&lt;p&gt;Here is the problematic, naive approach:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// BAD: This triggers N+1 queries
app.get('/api/posts', async (req, res) =&amp;gt; {
  try {
    // 1. Fetch all posts (The &quot;1&quot; query)
    const [posts] = await db.query('SELECT id, title, content FROM posts LIMIT 50');
    
    // 2. Loop through each post to fetch its comments (The &quot;N&quot; queries)
    for (let post of posts) {
      const [comments] = await db.query(
        'SELECT id, text FROM comments WHERE post_id = ?',
        [post.id]
      );
      post.comments = comments;
    }
    
    res.json(posts);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is how we resolve this using a SQL JOIN to pull all the information in a single, efficient query:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// GOOD: Resolved using a single JOIN query
app.get('/api/posts', async (req, res) =&amp;gt; {
  try {
    const query = `
      SELECT 
        p.id AS post_id, 
        p.title, 
        p.content, 
        c.id AS comment_id, 
        c.text AS comment_text
      FROM posts p
      LEFT JOIN comments c ON p.id = c.post_id
      LIMIT 50
    `;
    
    const [rows] = await db.query(query);
    
    // Group the flat SQL results into structured JSON
    const postsMap = {};
    for (const row of rows) {
      if (!postsMap[row.post_id]) {
        postsMap[row.post_id] = {
          id: row.post_id,
          title: row.title,
          content: row.content,
          comments: []
        };
      }
      if (row.comment_id) {
        postsMap[row.post_id].comments.push({
          id: row.comment_id,
          text: row.comment_text
        });
      }
    }
    
    res.json(Object.values(postsMap));
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-takeaway-2&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;The N+1 query problem highlights that how you fetch your data is just as important as the data itself. By switching from sequential, iterative loops to combined database operations like SQL JOINs, you minimize the highly expensive overhead of network round-trips. Designing your data fetching strategies proactively keeps your APIs incredibly fast and ensures your databases stay healthy, stable, and cost-efficient under high production loads.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/09/how-to-save-your-database-understanding.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27905/the-silent-performance-killer-demystifying-the-n-1-query-problem-in-node-js</guid>
<pubDate>Mon, 21 Sep 2026 09:18:48 +0000</pubDate>
</item>
<item>
<title>Rate Limiting Isn't One Layer: What LogicVisor and Titan Actually Do Differently</title>
<link>https://coderlegion.com/27708/rate-limiting-isnt-one-layer-what-logicvisor-and-titan-actually-do-differently</link>
<description>&lt;p&gt;&quot;Add rate limiting&quot; sounds like one task. It's actually three separate layers, each defending against a different failure mode, with different trade-offs and different amounts of trust you can place in them. I only understood the distinction properly once I had to pick a strategy for two systems solving different problems: LogicVisor (a public AI tool anyone can hit anonymously) and Titan (a payments platform where the cost of a bad actor is different from the cost of someone burning through free AI credits).&lt;/p&gt;
&lt;h3 id=&quot;1-client-side-ux-not-security&quot;&gt;1. Client-side: UX, not security&lt;/h3&gt;
&lt;p&gt;Debouncing a search input, greying out a submit button after the first click, backing off exponentially after a 429. All of this makes an app feel considerate. None of it stops anyone. A malicious actor skips your JavaScript entirely and hits the endpoint directly with curl. Client-side limiting is worth doing (it saves you real traffic and prevents accidental double-submits), but it is not a security control. If it's the only thing standing between your API and abuse, you don't have rate limiting, you have a polite suggestion.&lt;/p&gt;
&lt;h3 id=&quot;2-server-side-the-layer-that-actually-protects-you&quot;&gt;2. Server-side: the layer that actually protects you&lt;/h3&gt;
&lt;p&gt;This is where LogicVisor and Titan diverge, because they're not defending against the same thing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LogicVisor: several checks before a single AI token gets spent&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;LogicVisor is public. Anyone gets 3 free code reviews with no signup, which means the abuse surface is wide open by design. The submission route runs a stack of checks before it ever calls Gemini or Groq, because every AI call costs real money:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 1. Check if this exact code has already been reviewed by this model
const cachedReview = await getCachedAIReview(preferred_model.id + &quot;-&quot; + canonicalHash);
if (cachedReview) {
  return NextResponse.json({ success: true, data: cachedReview }, { status: 201 });
}

// 2. Enforce the actual rate limit (IP + session based for anon users)
const rateLimitResult = await enforceAIRateLimit(user.id, request);

// 3. Slow down premium users intelligently instead of hard-blocking them
const throttleDelay = await getThrottleDelay(user.id);
if (throttleDelay &amp;gt; 0) {
  await new Promise((resolve) =&amp;gt; setTimeout(resolve, throttleDelay));
}

// 4. Under heavy load, degrade gracefully instead of rejecting outright
const shouldDegrade = await shouldGracefullyDegrade(user.id, &quot;ai_request&quot;);
if (shouldDegrade) {
  // return a basic, non-AI response instead of a 429
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few things worth naming separately, because they get lumped together under &quot;rate limiting&quot; but aren't the same mechanism:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Abuse prevention&lt;/strong&gt;: anonymous sessions are capped at 3 reviews, tracked by a UUID session (7-day expiry) plus a browser fingerprint (User-Agent + Accept-Language + Accept-Encoding). This is the classic server-side layer, IP and fingerprint based, closest to the &quot;token bucket per identity&quot; pattern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost control via deduplication&lt;/strong&gt;: before rate limiting even runs, the code is canonicalized (AST-based) and hashed. If someone (or the same person twice) submits identical logic, they get the cached review instead of triggering a new paid AI call. This isn't rate limiting in the strict sense, it's closer to database-level duplicate detection, but it does the same job of protecting a scarce resource.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Graceful degradation over hard rejection&lt;/strong&gt;: instead of a flat 429 when usage spikes, authenticated users can get a stripped-down, non-AI response. It costs nothing and still gives the user something, rather than a wall.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this is a single algorithm out of a textbook. It's several cheap, layered checks, ordered so the most expensive resource (the AI call) is the last thing hit, not the first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Titan: NestJS Throttler backed by Redis&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Titan's rate limiting is intentionally boring by comparison, and that's the correct choice for what it is. It's &lt;code&gt;@nestjs/throttler&lt;/code&gt; wired up as a global guard, with Redis swapped in as the storage backend instead of the default in-memory store:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;ThrottlerModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (configService: ConfigService) =&amp;gt; ({
    throttlers: [
      {
        ttl: 60000,
        limit: 10,
      },
    ],
    storage: new ThrottlerStorageRedisService(
      configService.get&amp;lt;string&amp;gt;('REDIS_URL'),
    ),
  }),
}),

// registered alongside the JWT guard as a global APP_GUARD
{
  provide: APP_GUARD,
  useClass: ThrottlerGuard,
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;10 requests per 60 seconds, globally, enforced before a request even reaches a controller. No per-route tuning yet, no per-user tiers, just a flat ceiling applied everywhere via &lt;code&gt;APP_GUARD&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The reason Redis matters here isn't exotic: an in-memory counter only knows about requests hitting that one process. The moment you run more than one instance behind a load balancer, in-memory rate limiting stops being true rate limiting, each instance is independently under-counting. Swapping the storage to &lt;code&gt;ThrottlerStorageRedisService&lt;/code&gt; gives every instance a shared, consistent view of who's made how many requests, which is the actual requirement once you're not running a single box.&lt;/p&gt;
&lt;p&gt;LogicVisor's layered, identity-aware approach makes sense for a system where the main threat is &quot;someone is farming free AI reviews.&quot; Titan's flat, Redis-backed throttle makes sense for a system where the main threat is &quot;someone is hammering an endpoint,&quot; and the priority is consistency across instances over nuance per user.&lt;/p&gt;
&lt;h3 id=&quot;3-database-level-the-layer-i-m-deliberately-not-using-yet&quot;&gt;3. Database-level: the layer I'm deliberately not using yet&lt;/h3&gt;
&lt;p&gt;Postgres and Supabase both support rate limiting closer to the data layer (unlogged tables with triggers, PL/pgSQL token bucket functions, TTL-based counters in Mongo). I don't use this in either project, and I don't think I should yet. It's the right call for protecting specific high-value operations (financial writes, heavy analytical queries) where you need the database itself to be the source of truth and can't tolerate a race between the app layer and the DB. LogicVisor's canonical-hash cache lookup is adjacent to this idea (checking the data layer before doing expensive work), but it's a caching pattern, not a rate limiter, and I'd rather keep that distinction honest than dress it up as something it isn't.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-mattered-in-order&quot;&gt;What actually mattered, in order&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Client-side limiting is UX. Treat it as UX.&lt;/strong&gt; It's not a line item in your security review.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server-side is where the real decision lives&lt;/strong&gt;, and the right shape of it depends on what you're protecting: identity-based abuse (LogicVisor) versus flat request volume across a distributed system (Titan). They are not the same problem and don't need the same solution.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The most expensive resource should be the last thing your code touches.&lt;/strong&gt; LogicVisor checks a cache, then a rate limit, then a throttle delay, before it ever calls an LLM. Ordering is a rate limiting decision too, even when it's not framed as one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database-level limiting is a tool for a specific job&lt;/strong&gt;, not a default. I'm not reaching for it until there's an operation that actually needs that guarantee.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;&lt;small&gt;Cover photo by &lt;a rel=&quot;nofollow&quot; href=&quot;https://unsplash.com/@able527?utm_source=unsplash&amp;amp;utm_medium=referral&amp;amp;utm_content=creditCopyText&quot;&gt;HsinKai Tai&lt;/a&gt; on &lt;a rel=&quot;nofollow&quot; href=&quot;https://unsplash.com/photos/traffic-jam-at-a-toll-booth-on-a-highway-5rwx1kzWpFk?utm_source=unsplash&amp;amp;utm_medium=referral&amp;amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27708/rate-limiting-isnt-one-layer-what-logicvisor-and-titan-actually-do-differently</guid>
<pubDate>Sat, 19 Sep 2026 09:19:55 +0000</pubDate>
</item>
<item>
<title>How I Eliminated 19,494 Duplicate MongoDB Records (And What I'd Do Differently Today)</title>
<link>https://coderlegion.com/27703/how-i-eliminated-19-494-duplicate-mongodb-records-and-what-id-do-differently-today</link>
<description>&lt;p&gt;At a previous organization, I was handed ownership of a job platform serving 1,000+ users worldwide. Jobs on the platform could be scoped to a single city, a state, a whole country, or &quot;available worldwide.&quot; Simple enough. At least, you'd think so.&lt;/p&gt;
&lt;p&gt;When I joined, this was early in my career and I was a frontend dev. I was interested in backend work but hadn't done any real backend engineering yet — I wasn't battle-tested. Somewhere along the line, I got handed the whole platform. Frontend, backend, all of it. Bracing moment. I forced myself to slow down, actually understand the system end to end, and build a real plan before touching anything.&lt;/p&gt;
&lt;p&gt;After talking with product management, I pulled up the backend task backlog. I learn best by doing, so I started at the bottom — smallest bugs first, working my way up. It went well for a while. Then I hit a ticket tied to core business logic.&lt;/p&gt;
&lt;h3 id=&quot;the-wake-up-call&quot;&gt;The Wake-Up Call&lt;/h3&gt;
&lt;p&gt;Some endpoints were clocking API response times north of &lt;strong&gt;7,000ms&lt;/strong&gt;. I genuinely thought it was a typo. Then I remembered running into something similar on the frontend side before — so I patched the UX so users never felt the full weight of the delay. But the actual problem was still sitting there, untouched.&lt;/p&gt;
&lt;p&gt;Before touching any code, I made a checklist. We were running MongoDB with NestJS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What schemas was the endpoint touching?&lt;/li&gt;
&lt;li&gt;What optimization had already been applied to those schemas?&lt;/li&gt;
&lt;li&gt;How was the endpoint fetching data?&lt;/li&gt;
&lt;li&gt;How many records did each related schema currently hold?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Checklist in hand, I went into the codebase.&lt;/p&gt;
&lt;p&gt;My innocent dev brain was not prepared for the chaos I found.&lt;/p&gt;
&lt;h3 id=&quot;what-was-actually-happening&quot;&gt;What Was Actually Happening&lt;/h3&gt;
&lt;p&gt;Here's the setup: assume an empty &lt;code&gt;jobs&lt;/code&gt; collection. A recruiter creates a job listing scoped to &lt;strong&gt;New York City&lt;/strong&gt;. The backend takes the payload and creates one record with location set to New York City. Straightforward.&lt;/p&gt;
&lt;p&gt;Now assume another empty collection, and this time the job is scoped to the &lt;strong&gt;United States&lt;/strong&gt; instead of a single city. What do you think happens?&lt;/p&gt;
&lt;p&gt;If you guessed &quot;the same thing — one record, location set to United States&quot; — I have bad news for you.&lt;/p&gt;
&lt;p&gt;What the backend &lt;em&gt;actually&lt;/em&gt; did: it looked at the payload, recognized &quot;United States&quot; as a country rather than a city, looked up every known city in the U.S. — and created a separate job record for &lt;strong&gt;each one&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Read that again.&lt;/p&gt;
&lt;p&gt;Instead of one record, the platform generated one identical record per city, differing only in &lt;code&gt;_id&lt;/code&gt; and location. And it got worse: a job marked &quot;available worldwide&quot; would spawn a record for every city on the planet.&lt;/p&gt;
&lt;p&gt;This was a live product with real traffic. Querying that collection — even with indexes in place — was a nightmare.&lt;/p&gt;
&lt;p&gt;For context on scale: a single job scoped to just the USA generated &lt;strong&gt;19,495 records&lt;/strong&gt; — one canonical listing plus 19,494 exact duplicates.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Database records (1 USA-wide job) &lt;/td&gt;&lt;td&gt;19,495&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Exact duplicates among them &lt;/td&gt;&lt;td&gt;19,494&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Query time on affected endpoints &lt;/td&gt;&lt;td&gt;~2.3s (worst cases exceeded 7s for worldwide-scoped jobs)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;My first reaction was horrified confusion. After talking it over with management, I learned there &lt;em&gt;was&lt;/em&gt; a reason for the original design — I won't get into it here, it's not the point of this piece. What mattered was that I had free rein to fix it if I could pull it off. Hold my Pokémon card.&lt;/p&gt;
&lt;h3 id=&quot;the-constraints&quot;&gt;The Constraints&lt;/h3&gt;
&lt;p&gt;Fixing the architecture wasn't the hard part conceptually — the hard part was fixing it under real constraints:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We still needed to resolve &lt;em&gt;which&lt;/em&gt; cities a job was available in, for every existing feature that depended on that.&lt;/li&gt;
&lt;li&gt;Whatever I built had to slot into the existing infrastructure without a rewrite of everything downstream.&lt;/li&gt;
&lt;li&gt;I needed to show measurable, provable performance gains — not just &quot;trust me, it's better.&quot;&lt;/li&gt;
&lt;li&gt;None of this could disrupt daily active users. No maintenance windows, no downtime.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;designing-the-fix&quot;&gt;Designing the Fix&lt;/h3&gt;
&lt;p&gt;I started with the schema. The core question: how do I preserve full location fidelity while collapsing tens of thousands of records into one?&lt;/p&gt;
&lt;p&gt;I thought back to basic compression concepts — take data, strip it to a minimal representation, and pair it with the instructions needed to reconstruct the original. That's essentially what I needed here: a compact way to represent &quot;this job is available in X&quot; without one document per X.&lt;/p&gt;
&lt;p&gt;Two deliverables:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A new, efficient schema.&lt;/li&gt;
&lt;li&gt;A migration path that collapsed the old duplicated records into the new schema.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For the new schema, I kept every existing field and replaced the flat &lt;code&gt;location&lt;/code&gt; string with a structured &lt;code&gt;locationObj&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;interface LocationObj {
  country: string | string[];
  state: string | string[];
  city: string | string[];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The logic:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single city&lt;/strong&gt; → &lt;code&gt;city&lt;/code&gt; is a string, &lt;code&gt;state&lt;/code&gt; and &lt;code&gt;country&lt;/code&gt; resolve to that city's parent state/country.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A specific group of cities&lt;/strong&gt; → &lt;code&gt;city&lt;/code&gt; becomes a string array.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An entire state&lt;/strong&gt; → &lt;code&gt;city&lt;/code&gt; becomes the sentinel value &lt;code&gt;&quot;all&quot;&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt; holds the actual state name.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An entire country&lt;/strong&gt; → &lt;code&gt;state&lt;/code&gt; and &lt;code&gt;city&lt;/code&gt; both become &lt;code&gt;&quot;all&quot;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Worldwide&lt;/strong&gt; → &lt;code&gt;country&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, and &lt;code&gt;city&lt;/code&gt; all become &lt;code&gt;&quot;all&quot;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So a job available across Berlin, Munich, and Hamburg looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;{
  title: &quot;Marketing Manager&quot;,
  company: &quot;TechCorp&quot;,
  locationObj: {
    country: &quot;Germany&quot;,
    state: &quot;all&quot;,
    city: [&quot;Berlin&quot;, &quot;Munich&quot;, &quot;Hamburg&quot;]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And a job available anywhere in the world:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;{
  title: &quot;Software Developer&quot;,
  company: &quot;TechCorp&quot;,
  locationObj: {
    country: &quot;all&quot;,
    state: &quot;all&quot;,
    city: &quot;all&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One document. Full location semantics preserved. No fan-out.&lt;/p&gt;
&lt;h3 id=&quot;migrating-without-downtime&quot;&gt;Migrating Without Downtime&lt;/h3&gt;
&lt;p&gt;The deduplication piece ran as a batch script against the old collection: group records that shared everything &lt;em&gt;except&lt;/em&gt; location (same title, company, description, salary — the actual &quot;identity&quot; of a job), reconstruct the correct &lt;code&gt;locationObj&lt;/code&gt; from the set of location values in that group, and write a single new document into the new collection.&lt;/p&gt;
&lt;p&gt;Once the new collection was validated against the old one, I built new endpoints on top of the new schema, mirroring the contracts of the old ones, and prepared the frontend to point at them.&lt;/p&gt;
&lt;p&gt;The cutover itself was a backfill with two catch-up passes, not a one-shot migration:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Run the migration script once against the live old collection.&lt;/li&gt;
&lt;li&gt;Right before deploying the new endpoints, re-run it — this catches any jobs created &lt;em&gt;while&lt;/em&gt; the first pass was running, since the old collection kept accepting writes the entire time.&lt;/li&gt;
&lt;li&gt;Deploy the new endpoints and switch the frontend over.&lt;/li&gt;
&lt;li&gt;Re-run the script once more right after — this catches anything created under the old schema during the deploy window itself, in case the rollout wasn't instantaneous.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I tested the full path — frontend, backend, and DB — on local and staging before any of this touched production, so by the time it shipped, the swap itself was just a normal deploy. From a user's perspective, they kept seeing exactly the jobs they were supposed to see, the whole way through.&lt;/p&gt;
&lt;h3 id=&quot;querying-the-new-schema&quot;&gt;Querying the New Schema&lt;/h3&gt;
&lt;p&gt;The query logic has to walk the hierarchy — country, then state, then city — because &quot;all&quot; at any level short-circuits the rest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const query = {
  $or: [
    { &quot;locationObj.country&quot;: &quot;all&quot; },
    { &quot;locationObj.country&quot;: userCountry, &quot;locationObj.state&quot;: &quot;all&quot; },
    { &quot;locationObj.country&quot;: userCountry, &quot;locationObj.state&quot;: userState, &quot;locationObj.city&quot;: &quot;all&quot; },
    { &quot;locationObj.country&quot;: userCountry, &quot;locationObj.city&quot;: userCity },
    { &quot;locationObj.country&quot;: userCountry, &quot;locationObj.city&quot;: { $in: [userCity] } }
  ]
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Combined with a compound index on &lt;code&gt;locationObj.country&lt;/code&gt;, &lt;code&gt;locationObj.state&lt;/code&gt;, and &lt;code&gt;locationObj.city&lt;/code&gt;, this scales linearly with the number of &lt;em&gt;actual&lt;/em&gt; jobs — not the number of cities they happen to be available in.&lt;/p&gt;
&lt;h3 id=&quot;the-results&quot;&gt;The Results&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Before &lt;/th&gt;&lt;th&gt;After &lt;/th&gt;&lt;th&gt;Change&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Database records (1 USA-wide job) &lt;/td&gt;&lt;td&gt;19,495 &lt;/td&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;99.99% fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Query time &lt;/td&gt;&lt;td&gt;~2.3s &lt;/td&gt;&lt;td&gt;~0.12s &lt;/td&gt;&lt;td&gt;~95% faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Storage footprint &lt;/td&gt;&lt;td&gt;847 MB &lt;/td&gt;&lt;td&gt;0.043 MB &lt;/td&gt;&lt;td&gt;99.99% smaller&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index size &lt;/td&gt;&lt;td&gt;156 MB &lt;/td&gt;&lt;td&gt;0.008 MB &lt;/td&gt;&lt;td&gt;99.99% smaller&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Memory usage &lt;/td&gt;&lt;td&gt;~4.2 GB &lt;/td&gt;&lt;td&gt;~0.21 MB &lt;/td&gt;&lt;td&gt;99.99% less RAM&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Exact duplicate documents &lt;/td&gt;&lt;td&gt;19,494 &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;Eliminated&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Management was, unsurprisingly, impressed.&lt;/p&gt;
&lt;h3 id=&quot;how-i-d-do-it-now&quot;&gt;How I'd Do It Now&lt;/h3&gt;
&lt;p&gt;Looking back at this with a few more years under my belt, the schema still holds up. What I'd change is the migration and verification process — at the time, &quot;backfill, then re-run twice around the cutover&quot; felt rigorous. It worked. But it also relied on timing more than I'd want to admit if I were reviewing this today.&lt;/p&gt;
&lt;p&gt;The more rigid version of this — the one I'd actually reach for now — is the &lt;strong&gt;expand-contract pattern&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Expand&lt;/strong&gt;: Deploy dual-writes behind a feature flag — every job creation or update writes to both the old and new schema at the same time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sync&lt;/strong&gt;: Use MongoDB Change Streams to mirror any old-schema writes into the new schema in real time during the transition, instead of relying on timed reruns of a migration script. This closes the race-condition window entirely — there's no gap to miss, because nothing is waiting for a scheduled pass.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contract&lt;/strong&gt;: Once reads are fully on the new schema and stable, kill the old write path.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I'd also make the migration script itself &lt;strong&gt;idempotent and checkpointed&lt;/strong&gt; — track the last processed &lt;code&gt;_id&lt;/code&gt; so a crash mid-run means it resumes instead of reprocessing or silently skipping documents. Mine was a script I babysat through completion; a checkpointed version is one I could walk away from.&lt;/p&gt;
&lt;p&gt;On verification — &quot;compare the counts&quot; was good enough to convince management, but it wasn't rigorous. I'd now do a real reconciliation pass: diff the reconstructed &lt;code&gt;locationObj&lt;/code&gt; against the actual set of cities in the old schema, per job, and flag any mismatch automatically instead of trusting a spot check. And before flipping reads over, I'd run a dark-launch period — querying both schemas in production and logging discrepancies without serving the shadow result to users — so any gap shows up before it's user-facing, not after.&lt;/p&gt;
&lt;p&gt;One more thing I'd change that isn't about the migration at all: I'd want this caught before it ever shipped. The root failure was that nothing stopped a single job-creation event from silently producing thousands of documents. A write-layer guardrail — &quot;creating one job should never result in more than one document, alert if it does&quot; — plus a five-line regression test asserting exactly that, would have caught this in review instead of in production months later.&lt;/p&gt;
&lt;h3 id=&quot;lessons-learned&quot;&gt;Lessons Learned&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Embedding vs. referencing isn't the real question — normalization is.&lt;/strong&gt; The original design wasn't wrong because it embedded or referenced incorrectly; it was wrong because it materialized every possible resolution of a hierarchical value instead of storing the hierarchy itself. The general MongoDB advice of &quot;embed for read-heavy, reference for write-heavy&quot; didn't apply here — the fix was closer to a compression problem than a modeling problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sentinel values are underrated.&lt;/strong&gt; Using &lt;code&gt;&quot;all&quot;&lt;/code&gt; as a stand-in for &quot;every value at this level&quot; let one field type (&lt;code&gt;string | string[]&lt;/code&gt;) represent five distinct scopes without a single boolean flag or separate &lt;code&gt;scope&lt;/code&gt; enum.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the actual scale of the problem before designing the fix.&lt;/strong&gt; I didn't just need &quot;make it faster&quot; — I needed a number to prove it. Knowing it was 19,495 records for &lt;em&gt;one job&lt;/em&gt; is what made the case to management, and it's what makes this story worth telling.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Fields, numbers, and code above are reconstructed from a project where I owned the full backend under an NDA — details are generalized where necessary, but the architecture, the failure mode, and the metrics are real.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;small&gt;Cover photo by &lt;a rel=&quot;nofollow&quot; href=&quot;https://unsplash.com/@kommumikation?utm_source=unsplash&amp;amp;utm_medium=referral&amp;amp;utm_content=creditCopyText&quot;&gt;Mika Baumeister&lt;/a&gt; on &lt;a rel=&quot;nofollow&quot; href=&quot;https://unsplash.com/photos/white-printing-paper-with-numbers-Wpnoqo2plFA?utm_source=unsplash&amp;amp;utm_medium=referral&amp;amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/27703/how-i-eliminated-19-494-duplicate-mongodb-records-and-what-id-do-differently-today</guid>
<pubDate>Sat, 19 Sep 2026 08:33:15 +0000</pubDate>
</item>
<item>
<title>The Silent Database Killer: Understanding and Fixing the N+1 Query Problem</title>
<link>https://coderlegion.com/27488/the-silent-database-killer-understanding-and-fixing-the-n-1-query-problem</link>
<description>&lt;p&gt;The N+1 query problem is a performance bottleneck that occurs when an application makes database queries inside a loop. Instead of fetching all the required data in a single, efficient query, the system executes one initial query to fetch a list of parent records, followed by &quot;N&quot; separate queries to fetch related child records for each item in that list. This creates an exponential number of database requests as your data grows.&lt;/p&gt;
&lt;h4 id=&quot;the-grocery-store-analogy&quot;&gt;The Grocery Store Analogy&lt;/h4&gt;
&lt;p&gt;Imagine you are baking a cake and need five different ingredients from your local grocery store. Instead of writing all five items on a single list and making one trip, you drive to the store, buy flour, and drive home. Then, you realize you need sugar, so you drive back to the store, buy sugar, and drive home. You repeat this entire round-trip for every single ingredient. This is highly inefficient, wasting time, fuel, and energy. In software, each trip to the database is like one of those painful, redundant car rides.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-in-the-tech-industry-1&quot;&gt;Why It Matters in the Tech Industry&lt;/h4&gt;
&lt;p&gt;On a daily basis, software engineers actively fight N+1 queries to prevent application servers and databases from crashing under heavy traffic. Each extra query introduces network latency and forces the database to parse, plan, and execute a new command. This wastes valuable connection pools, spikes CPU usage, and causes page load times to balloon. Fixing this issue is often the difference between a snappy, responsive web app and one that times out during peak business hours.&lt;/p&gt;
&lt;h4 id=&quot;spotting-and-fixing-the-problem-in-node-js&quot;&gt;Spotting and Fixing the Problem in Node.js&lt;/h4&gt;
&lt;p&gt;Let's look at how this happens in a Node.js and Express application querying a MySQL database, and how to fix it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// THE PROBLEM: The N+1 Query Way
app.get('/users-with-posts-bad', async (req, res) =&amp;gt; {
  // 1. First query to get all users (The &quot;1&quot; in N+1)
  const [users] = await db.query('SELECT id, username FROM users');

  const usersWithPosts = [];

  // 2. Loop through each user and query their posts (The &quot;N&quot; queries)
  for (const user of users) {
    const [posts] = await db.query('SELECT id, title FROM posts WHERE user_id = ?', [user.id]);
    usersWithPosts.push({
      ...user,
      posts
    });
  }

  res.json(usersWithPosts);
});

// THE SOLUTION: The Efficient Way (Using a SQL JOIN)
app.get('/users-with-posts-good', async (req, res) =&amp;gt; {
  // A single query fetches all users and their posts at once
  const query = `
    SELECT u.id AS userId, u.username, p.id AS postId, p.title
    FROM users u
    LEFT JOIN posts p ON u.id = p.user_id
  `;
  const [rows] = await db.query(query);

  // Format the flat SQL result into structured JSON
  const usersMap = {};
  for (const row of rows) {
    if (!usersMap[row.userId]) {
      usersMap[row.userId] = {
        id: row.userId,
        username: row.username,
        posts: []
      };
    }
    if (row.postId) {
      usersMap[row.userId].posts.push({
        id: row.postId,
        title: row.title
      });
    }
  }

  res.json(Object.values(usersMap));
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-takeaway-3&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;Database interactions are highly expensive operations. Always aim to retrieve data in bulk sets rather than looping over individual records. By shifting your mindset from iterative programming to relational database operations, you can transform a slow, unstable backend into a highly optimized, production-ready system that scales effortlessly.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/09/is-your-code-making-too-many-database.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27488/the-silent-database-killer-understanding-and-fixing-the-n-1-query-problem</guid>
<pubDate>Thu, 17 Sep 2026 08:54:44 +0000</pubDate>
</item>
<item>
<title>Your Reader Cannot Be Stricter Than Your Schema</title>
<link>https://coderlegion.com/27483/your-reader-cannot-be-stricter-than-your-schema</link>
<description>&lt;p&gt;&lt;code&gt;SingleOrDefault&lt;/code&gt; looks like a small query choice. In practice, it is an assertion about your data model: for this predicate, zero or one row may exist.&lt;/p&gt;
&lt;p&gt;That assertion is valuable when storage enforces the same rule. When the database permits duplicates, however, the reader is relying on an invariant that does not exist. One unexpected row can turn an ordinary lookup into an exception before validation, authorization, or other normal handling begins.&lt;/p&gt;
&lt;p&gt;I recently reviewed a committed recovery for exactly that class of mismatch. The useful lesson was not the specific lookup. It was how to contain the exception without pretending the data had become correct.&lt;/p&gt;
&lt;h3 id=&quot;a-query-can-encode-a-hidden-contract&quot;&gt;A query can encode a hidden contract&lt;/h3&gt;
&lt;p&gt;Consider a generalized account table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Rows permitted by storage: 0..many per normalized key
Rows expected by reader:    0..1 per normalized key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The read path might be perfectly reasonable in isolation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var record = await records.SingleOrDefaultAsync(x =&amp;gt; x.LookupKey == key);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But the method name does not create uniqueness. It only checks cardinality at read time. If two rows match, the query throws.&lt;/p&gt;
&lt;p&gt;This is why I now treat &lt;code&gt;Single&lt;/code&gt; and &lt;code&gt;SingleOrDefault&lt;/code&gt; as executable invariant assertions. During review, I ask: what proves the invariant? A unique index? A constrained aggregate? A transactionally protected write path? Or only an earlier existence check that can race?&lt;/p&gt;
&lt;h3 id=&quot;containment-is-not-repair&quot;&gt;Containment is not repair&lt;/h3&gt;
&lt;p&gt;When duplicate data already exists, immediately adding a unique index may be unsafe. Migration can fail on the existing rows. Previously accepted imports or registrations may begin failing without a useful remediation path.&lt;/p&gt;
&lt;p&gt;At the same time, leaving a critical read path to throw can make a narrow data defect disproportionately disruptive.&lt;/p&gt;
&lt;p&gt;A staged response separates two goals:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Containment:&lt;/strong&gt; provide a deterministic, non-crashing resolution path while preserving security and making the defect visible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Correction:&lt;/strong&gt; repair the existing data and enforce the intended rule for future writes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The distinction matters. A tolerant reader is a recovery mechanism, not evidence that duplicates are acceptable.&lt;/p&gt;
&lt;h3 id=&quot;properties-of-a-safe-recovery-path&quot;&gt;Properties of a safe recovery path&lt;/h3&gt;
&lt;p&gt;A useful containment pattern keeps the normal case boring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;try
{
    return await ReadExpectedSingleAsync(key);
}
catch (InvalidOperationException)
{
    var matches = await ReadBoundedCandidatesAsync(key);
    if (matches.Count &amp;lt; 2)
        throw;

    ReportCollision(matches.Select(x =&amp;gt; x.OpaqueId));
    return ChooseDeterministically(matches);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This sketch is intentionally incomplete, but it highlights several important properties.&lt;/p&gt;
&lt;h4 id=&quot;keep-the-fast-path-unchanged&quot;&gt;Keep the fast path unchanged&lt;/h4&gt;
&lt;p&gt;Most lookups are not ambiguous. Do not make every request pay for a corruption-recovery query. Invoke the fallback only after the expected lookup demonstrates the precise failure you are prepared to handle.&lt;/p&gt;
&lt;h4 id=&quot;prove-ambiguity-before-handling-it&quot;&gt;Prove ambiguity before handling it&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;InvalidOperationException&lt;/code&gt; can come from many places. Catching it and returning “not found” would turn an unrelated store failure into a silent wrong answer. Re-query narrowly; if there are not actually multiple matches, rethrow the original fault.&lt;/p&gt;
&lt;h4 id=&quot;choose-deterministically&quot;&gt;Choose deterministically&lt;/h4&gt;
&lt;p&gt;Never accept database enumeration order as a resolution policy. Define explicit ranking from legitimate domain facts, then add a stable final tie-break. The same data must produce the same candidate on every attempt.&lt;/p&gt;
&lt;p&gt;Determinism does not make the selected record correct. It prevents the recovery itself from becoming random.&lt;/p&gt;
&lt;h4 id=&quot;preserve-downstream-verification&quot;&gt;Preserve downstream verification&lt;/h4&gt;
&lt;p&gt;Resolving one candidate must not grant access, approve a transaction, or bypass an eligibility rule. Credential checks, authorization, state validation, and other security decisions still run after resolution. Containment should make the lookup deterministic, not weaken the boundaries that follow it. A legitimate candidate may still lose the ranking, so some affected operations can remain unavailable until the data is repaired.&lt;/p&gt;
&lt;h4 id=&quot;bound-and-observe-the-fallback&quot;&gt;Bound and observe the fallback&lt;/h4&gt;
&lt;p&gt;A pathological key should not cause an unbounded scan during a critical request. Cap diagnostic reads, raise a high-signal operational event, and log only what responders need. Opaque record identifiers may support repair; the sensitive lookup value often does not belong in logs.&lt;/p&gt;
&lt;h3 id=&quot;the-stronger-end-state-lives-in-storage&quot;&gt;The stronger end state lives in storage&lt;/h3&gt;
&lt;p&gt;After containment, the work is not finished. The durable sequence is usually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;inventory collisions
-&amp;gt; decide merge or deletion policy
-&amp;gt; repair existing rows
-&amp;gt; reject or reconcile conflicting writes
-&amp;gt; add the storage constraint
-&amp;gt; remove or narrow temporary recovery code
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For relational data, that often means a unique index aligned with the normalization and null semantics used by the application. The write path should return a useful domain result when the constraint rejects a conflict. A pre-insert lookup can improve the message, but the database remains the concurrency-safe authority.&lt;/p&gt;
&lt;p&gt;There is a real trade-off. Immediate enforcement gives the cleanest future guarantee but can break deployment against historical data. Temporary tolerance contains the exception but adds policy, cannot guarantee later verification will succeed, and can become permanent by neglect. Treat it as migration work with an owner, telemetry, and an exit condition.&lt;/p&gt;
&lt;h3 id=&quot;test-the-invariant-and-the-escape-hatch&quot;&gt;Test the invariant and the escape hatch&lt;/h3&gt;
&lt;p&gt;Focused tests should cover more than “duplicates do not throw.” I would pin:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the ordinary single-row path remains untouched;&lt;/li&gt;
&lt;li&gt;selection is stable when candidates arrive in different orders;&lt;/li&gt;
&lt;li&gt;inactive or retired records cannot outrank valid ones;&lt;/li&gt;
&lt;li&gt;unrelated exceptions escape unchanged;&lt;/li&gt;
&lt;li&gt;downstream verification still decides the outcome;&lt;/li&gt;
&lt;li&gt;diagnostics contain repairable identifiers but no sensitive key;&lt;/li&gt;
&lt;li&gt;the candidate read is bounded;&lt;/li&gt;
&lt;li&gt;the future uniqueness constraint rejects a conflicting concurrent write.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The deeper lesson is architectural: reads, writes, and storage must agree on cardinality. If a reader says “exactly one,” make that promise explicit at the boundary able to enforce it. Until then, the code is not reading an invariant. It is discovering, at runtime, whether the hope was true.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27483/your-reader-cannot-be-stricter-than-your-schema</guid>
<pubDate>Thu, 17 Sep 2026 08:20:48 +0000</pubDate>
</item>
<item>
<title>Top Vector Databases for AI Agents in 2026: Qdrant vs Pinecone vs Weaviate vs PgVector vs Milvus</title>
<link>https://coderlegion.com/27480/top-vector-databases-for-ai-agents-in-2026-qdrant-vs-pinecone-vs-weaviate-pgvector-milvus</link>
<description>&lt;h2 id=&quot;top-vector-databases-for-ai-agents-in-2026-qdrant-vs-pinecone-vs-weaviate-vs-pgvector-vs-milvus&quot;&gt;Top Vector Databases for AI Agents in 2026: Qdrant vs Pinecone vs Weaviate vs PgVector vs Milvus&lt;/h2&gt;
&lt;p&gt;Persistent memory is the foundation that turns a stateless LLM into a continuously improving, autonomous agent.&lt;/p&gt;
&lt;p&gt;In 2026, selecting a vector database is no longer just about raw Approximate Nearest Neighbor (ANN) speed. For AI agents, the critical requirements have shifted to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Payload &amp;amp; Metadata Filtering&lt;/strong&gt;: Can you filter by &lt;code&gt;tenant_id&lt;/code&gt;, &lt;code&gt;user_id&lt;/code&gt;, and timestamp &lt;em&gt;during&lt;/em&gt; vector graph traversal without sacrificing recall?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid Search (BM25 + Dense Vectors + Sparse SPLADE)&lt;/strong&gt;: Combining exact keyword matching (for code symbols and error codes) with semantic understanding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-Tenancy &amp;amp; Memory Namespacing&lt;/strong&gt;: Safely isolating memory blocks across thousands of users and sessions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Billion-Scale Quantization (Product Quantization &amp;amp; Scalar Quantization)&lt;/strong&gt;: Slashing RAM costs by 75–90% in production.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This guide provides a comprehensive architectural comparison of the top 5 vector databases for AI agents in 2026.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;head-to-head-comparison-matrix&quot;&gt;Head-to-Head Comparison Matrix&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Feature / Metric &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Qdrant &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Pinecone (Serverless) &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Weaviate &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;PgVector (PostgreSQL) &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Milvus&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Primary Architecture&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Rust-native, disk-backed &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Fully managed serverless &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Go-native, modular RAG &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;PostgreSQL extension &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Distributed cloud-native&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Open Source&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Yes (Apache 2.0) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Proprietary SaaS &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Yes (BSD-3) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Yes (Open Source) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Yes (Apache 2.0)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Payload Filtering&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Exceptional (HNSW custom payload indexing) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Good (Metadata filtering) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Strong (Inverted index + HNSW) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;SQL WHERE clause &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Strong (Partition keys)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Hybrid Search&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Native (Dense + Sparse vectors) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Native hybrid &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Native BM25 + Vector &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;SQL text search + pgvector &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Native multi-vector&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Quantization&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Scalar &amp;amp; Product Quantization (Binary) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Automatic serverless compression &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;PQ, BQ, SQ &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Halfvec, Binary Quantization &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Scalar / Product Quantization&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Best Fit&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;High-performance agent memory &amp;amp; self-hosted RAG &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Zero-maintenance cloud SaaS &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;GraphQL &amp;amp; multi-modal search &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Unified relational + vector apps &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Ultra-large enterprise (100M+ vectors)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-qdrant-the-rust-powered-standard-for-agent-memory&quot;&gt;1. Qdrant: The Rust-Powered Standard for Agent Memory&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://qdrant.tech&quot;&gt;Qdrant&lt;/a&gt; has emerged as the developer favorite for building agent memory systems (e.g. Mem0, LangChain, CrewAI).&lt;/p&gt;
&lt;h4 id=&quot;why-builders-choose-qdrant-&quot;&gt;Why Builders Choose Qdrant:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rust Performance&lt;/strong&gt;: Negligible latency overhead with predictable memory usage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fast Filtered Search&lt;/strong&gt;: Indexes payload fields directly inside the HNSW graph, preventing the notorious &quot;over-filtering&quot; recall collapse.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Binary Quantization&lt;/strong&gt;: Compresses embeddings by up to 32x, enabling in-memory vector search over millions of documents on standard hardware.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, Filter, FieldCondition, MatchValue

client = QdrantClient(url=&quot;http://localhost:6333&quot;)

# Create multi-tenant collection for Agent Memory
client.create_collection(
    collection_name=&quot;agent_memories&quot;,
    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

# Search strictly within user namespace
search_results = client.search(
    collection_name=&quot;agent_memories&quot;,
    query_vector=[0.05] * 1536,
    query_filter=Filter(
        must=[
            FieldCondition(key=&quot;user_id&quot;, match=MatchValue(value=&quot;user_12345&quot;)),
            FieldCondition(key=&quot;memory_type&quot;, match=MatchValue(value=&quot;preference&quot;))
        ]
    ),
    limit=5
)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-pinecone-serverless-the-zero-devops-standard&quot;&gt;2. Pinecone Serverless: The Zero-DevOps Standard&lt;/h3&gt;
&lt;p&gt;If your team does not want to manage clusters, backups, or index sharding, &lt;strong&gt;Pinecone Serverless&lt;/strong&gt; separates storage (S3/GCS) from compute (stateless query workers), delivering cost efficiency at variable agent traffic loads.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-pgvector-unified-relational-vector-storage&quot;&gt;3. PgVector: Unified Relational + Vector Storage&lt;/h3&gt;
&lt;p&gt;For teams already running PostgreSQL, &lt;strong&gt;pgvector&lt;/strong&gt; and &lt;strong&gt;pgvectorscale&lt;/strong&gt; eliminate the complexity of running a secondary vector database. You can join relational customer data directly with vector embeddings in a single ACID transaction.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;architectural-recommendation-in-2026&quot;&gt;Architectural Recommendation in 2026&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Choose Qdrant&lt;/strong&gt; if you want top-tier filtered search, self-hosting flexibility, and efficient binary quantization for agent memory layers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Pinecone&lt;/strong&gt; if you need fully managed serverless infrastructure with zero operational overhead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose PgVector&lt;/strong&gt; if your application is tightly coupled to relational PostgreSQL data and you want ACID guarantees.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Milvus&lt;/strong&gt; if your dataset exceeds 100M+ vectors across distributed Kubernetes clusters.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Compare all vector databases, benchmarks, and memory layers at &lt;a rel=&quot;nofollow&quot; href=&quot;https://agdex.ai&quot;&gt;AgDex.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27480/top-vector-databases-for-ai-agents-in-2026-qdrant-vs-pinecone-vs-weaviate-pgvector-milvus</guid>
<pubDate>Thu, 17 Sep 2026 07:46:47 +0000</pubDate>
</item>
<item>
<title>Stop Recreating Database Connections: An Illustrated Guide to Connection Pooling</title>
<link>https://coderlegion.com/25256/stop-recreating-database-connections-an-illustrated-guide-to-connection-pooling</link>
<description>&lt;p&gt;If you have ever built an application that talks to a database, you have used a database connection. A database is simply a structured storage system for digital information, and a connection is the secure, active pipeline established between your application's code and that database. &lt;/p&gt;
&lt;p&gt;However, constantly opening and closing these connections for every single user request is incredibly slow and resource-heavy. To solve this, developers use a technique called &lt;strong&gt;connection pooling&lt;/strong&gt;. Connection pooling is a performance optimization method where a group (or &quot;pool&quot;) of database connections is kept open and idle, waiting to be reused by whoever needs them, rather than being destroyed and recreated on demand.&lt;/p&gt;
&lt;h4 id=&quot;the-analogy-the-pizza-delivery-fleet&quot;&gt;The Analogy: The Pizza Delivery Fleet&lt;/h4&gt;
&lt;p&gt;To understand why connection pooling is so vital, imagine you run a busy, high-volume pizza restaurant. Every time an order comes in, you need to deliver it to a customer. &lt;/p&gt;
&lt;p&gt;Imagine if, for every single delivery, you had to hire a brand-new driver, run a background check, sign a legal employment contract, set up their vehicle registration, and purchase a GPS. Once that single delivery was complete, you immediately fired them, canceled their insurance, and sold the car. It sounds absurd, right? The administrative overhead of hiring and firing would take three times longer than the actual delivery, and your restaurant would go bankrupt in a week.&lt;/p&gt;
&lt;p&gt;Instead, you hire a permanent, dedicated fleet of five delivery drivers. They sit in the break room, fully certified and ready. When a pizza is boxed, the first available driver grabs the box, drives to the house, delivers it, and returns to wait in the break room for the next order. &lt;/p&gt;
&lt;p&gt;In this analogy, the drivers are the &quot;connection pool,&quot; the deliveries are your database queries (requests for information), and the hiring process is the network handshake required to open a new database connection.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-in-daily-software-engineering-1&quot;&gt;Why It Matters in Daily Software Engineering&lt;/h4&gt;
&lt;p&gt;In real-world software, opening a database connection is highly expensive in terms of computing power. The application must perform a security handshake, authenticate its username and password, allocate memory on the database server, and establish a network route. This process can easily take 50 to 100 milliseconds.&lt;/p&gt;
&lt;p&gt;If your website experiences a surge in traffic—say, 1,000 users clicking a button at the exact same moment—your server would try to open 1,000 separate connections simultaneously. The database server would quickly run out of memory, slow down to a crawl, and eventually crash, displaying a &quot;database connection error&quot; to your users.&lt;/p&gt;
&lt;p&gt;Connection pooling prevents this complete system collapse. It sets a strict limit on the maximum number of connections allowed (e.g., capping it at 20). If 1,000 users arrive, they politely share those 20 open connections. Because each connection is already established, queries execute in 1 to 2 milliseconds. Once a query is done, the connection is instantly recycled for the next user in line, protecting your database from crashing.&lt;/p&gt;
&lt;h4 id=&quot;a-simple-code-example-in-node-js&quot;&gt;A Simple Code Example in Node.js&lt;/h4&gt;
&lt;p&gt;Below is a simple JavaScript example using a popular PostgreSQL database driver. It demonstrates how we initialize a pool and query the database without manually opening and closing connections.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const { Pool } = require('pg');

// 1. Create a pool with a maximum capacity of 10 connections
const pool = new Pool({
  user: 'db_user',
  host: 'database.server.com',
  database: 'mydb',
  password: 'securepassword',
  port: 5432,
  max: 10, // Maximum connections in the pool
});

async function fetchUserData(userId) {
  // 2. Grab an already-open connection from the pool
  const client = await pool.connect();
  
  try {
    // 3. Execute the database query
    const result = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
    return result.rows[0];
  } catch (err) {
    console.error('Database query error:', err);
  } finally {
    // 4. Crucial: Release the connection back to the pool
    // This does NOT close the connection; it just makes it available for others!
    client.release();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-takeaway-4&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;Connection pooling turns a heavy, repetitive administrative chore into a fast, shared utility. By keeping a smart, limited buffer of active connections ready to work, software systems can handle tens of thousands of requests smoothly, ensuring high-speed page loads while protecting underlying servers from sudden traffic spikes.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;resources&quot;&gt;Resources&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Saurav-TB-Pandey/react-hook-lab&quot;&gt;react-hook-lab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;react-hook-lab:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/react-hook-lab&quot;&gt;npm package&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connect with me on LinkedIn:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/pandeysaurav/&quot;&gt;Saurav Pandey&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/08/how-connection-pooling-speeds-up-your.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/saurav_tb_pandey/stop-recreating-database-connections-an-illustrated-guide-to-connection-pooling-4i64&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25256/stop-recreating-database-connections-an-illustrated-guide-to-connection-pooling</guid>
<pubDate>Fri, 11 Sep 2026 02:43:51 +0000</pubDate>
</item>
<item>
<title>Speed Up Your Queries: A Plain-English Guide to Database Indexing</title>
<link>https://coderlegion.com/25254/speed-up-your-queries-a-plain-english-guide-to-database-indexing</link>
<description>&lt;h4 id=&quot;what-is-database-indexing-&quot;&gt;What is Database Indexing?&lt;/h4&gt;
&lt;p&gt;Database indexing is a technique used by software systems to speed up the retrieval of data from a database table. By creating a separate, organized pointer structure, the database can locate specific records without having to search through every single row of data. Think of it as a specialized shortcut map for your digital filing cabinet.&lt;/p&gt;
&lt;h4 id=&quot;the-library-card-catalog-analogy&quot;&gt;The Library Card Catalog Analogy&lt;/h4&gt;
&lt;p&gt;Imagine walking into a massive metropolitan library containing 100,000 books. If you want to find a book titled &quot;The Midnight Mystery&quot; and there is no catalog or organization system, you would have to start at the first shelf in the corner and look at every single book cover one by one until you find it. This slow, painful process is what a database does during a &quot;full table scan.&quot;&lt;/p&gt;
&lt;p&gt;Now, imagine the library has an alphabetical card catalog. You walk straight to the drawer marked &quot;M,&quot; find the index card for &quot;The Midnight Mystery,&quot; and read the card. It tells you exactly which aisle, shelf, and position the book is on. You walk directly there and grab it in seconds. In this scenario, the card catalog is the index, the books are the rows of data in your table, and the physical location is the memory address of the database record.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-daily-in-tech&quot;&gt;Why It Matters Daily in Tech&lt;/h4&gt;
&lt;p&gt;In the tech industry, engineers use database indexing to prevent application lag and catastrophic server crashes. Without indexes, as a company's database grows from thousands of rows to millions, simple daily actions like logging in, searching for an item, or pulling up a user profile would take seconds or even minutes instead of milliseconds.&lt;/p&gt;
&lt;p&gt;Slow database queries hog the system's central processing unit (CPU) and memory, causing other requests to back up and eventually freeze the entire application. By adding appropriate indexes, developers keep application programming interfaces (APIs) lightning-fast, reduce cloud hosting bills (because servers do not have to work as hard), and ensure a smooth user experience. However, engineers must find a balance: every index takes up storage space and slows down write operations (like adding or updating data), because the database has to update both the main table and the index every single time a change is made.&lt;/p&gt;
&lt;h4 id=&quot;how-it-works-in-practice&quot;&gt;How It Works in Practice&lt;/h4&gt;
&lt;p&gt;Here is a simple example in SQL (Structured Query Language). Imagine a system trying to look up a user by their email address.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Without an index, the database must scan every row in the users table
SELECT * FROM users WHERE email = 'developer@example.com';

-- To speed this query up, we create an index on the email column
CREATE INDEX idx_users_email ON users(email);

-- Now, the database uses the index to jump directly to the correct user
SELECT * FROM users WHERE email = 'developer@example.com';
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-takeaway-5&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;Database indexing is the ultimate balancing act of software performance optimization. It turns grueling, resource-heavy data searches into near-instant lookups, keeping applications snappy as they scale up to handle millions of users. Just remember that indexes are not free; treat them like directory signs in a physical store—use them where visitors frequently get lost, but do not clutter every wall with them, or you will slow down the inventory restocking process.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;resources-1&quot;&gt;Resources&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Saurav-TB-Pandey/react-hook-lab&quot;&gt;react-hook-lab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;react-hook-lab:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/react-hook-lab&quot;&gt;npm package&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connect with me on LinkedIn:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/pandeysaurav/&quot;&gt;Saurav Pandey&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/08/the-secret-to-fast-apps-understanding.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/saurav_tb_pandey/speed-up-your-queries-a-plain-english-guide-to-database-indexing-21a3&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25254/speed-up-your-queries-a-plain-english-guide-to-database-indexing</guid>
<pubDate>Fri, 11 Sep 2026 02:43:19 +0000</pubDate>
</item>
<item>
<title>Alembic in Practice: Structure, Revisions, and the Traps SQLite Sets</title>
<link>https://coderlegion.com/26691/alembic-in-practice-structure-revisions-and-the-traps-sqlite-sets</link>
<description>&lt;p&gt;&lt;em&gt;What those hex filenames mean, why migrations silently applied to the wrong database for two months, and how to widen a CHECK constraint without quietly breaking it.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Alembic is the migration tool for SQLAlchemy. Most tutorials get you as far as&lt;br&gt;
&lt;code&gt;alembic revision --autogenerate&lt;/code&gt; and stop, which leaves you unprepared for the&lt;br&gt;
two things that actually go wrong: SQLite's refusal to alter constraints, and&lt;br&gt;
configuration that points migrations at a different database than your&lt;br&gt;
application uses.&lt;/p&gt;
&lt;p&gt;This post covers both, using real migrations from&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/boazleleina/finops-sentinel&quot;&gt;FinOps Sentinel&lt;/a&gt;, an AWS cost&lt;br&gt;
agent with four migrations, one of which took three attempts to get right.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-layout&quot;&gt;The layout&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;alembic.ini                     # config; script location, logging
alembic/
├── env.py                      # runs on every alembic command
├── script.py.mako              # template for new revision files
└── versions/
    ├── c447645246ac_initial_schema.py
    ├── 08872b80af2e_notifications_and_remediations.py
    ├── a1c9f4d27b13_add_rds_and_s3_resource_types.py
    └── b7e2f81c4a90_add_spend_snapshots.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Plus one table Alembic creates in your database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One row. One value. That is the entirety of Alembic's state: which database is&lt;br&gt;
at which revision. Everything else is derived.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-the-filenames-look-like-keyboard-mashing&quot;&gt;Why the filenames look like keyboard mashing&lt;/h3&gt;
&lt;p&gt;This is the most common first question, and the answer explains the design.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;c447645246ac_initial_schema.py
b7e2f81c4a90_add_spend_snapshots.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hex prefix is the &lt;strong&gt;revision ID&lt;/strong&gt;: a 12-character slice of a UUID, generated&lt;br&gt;
by &lt;code&gt;alembic revision&lt;/code&gt;. The filename is &lt;code&gt;&amp;lt;revision_id&amp;gt;_&amp;lt;your message slugified&amp;gt;.py&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Inside each file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;revision: str = &quot;b7e2f81c4a90&quot;
down_revision: Union[str, None] = &quot;a1c9f4d27b13&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those two fields form a &lt;strong&gt;linked list&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;c447645246ac  initial_schema                  (down_revision = None)  ← root
      ↓
08872b80af2e  notifications_and_remediations
      ↓
a1c9f4d27b13  add_rds_and_s3_resource_types
      ↓
b7e2f81c4a90  add_spend_snapshots                                     ← head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Order comes from the pointers, never from filenames.&lt;/strong&gt; Look at the project's&lt;br&gt;
list again: &lt;code&gt;b7e2f81c4a90&lt;/code&gt; sorts alphabetically &lt;em&gt;before&lt;/em&gt; &lt;code&gt;c447645246ac&lt;/code&gt;, yet&lt;br&gt;
&lt;code&gt;c447645246ac&lt;/code&gt; is the first migration and &lt;code&gt;b7e2f81c4a90&lt;/code&gt; is the newest. If&lt;br&gt;
Alembic sorted by filename, everything would run backwards.&lt;/p&gt;
&lt;h4 id=&quot;why-not-0001-0002-0003-&quot;&gt;Why not 0001, 0002, 0003?&lt;/h4&gt;
&lt;p&gt;Because sequential numbers collide the moment two people work in parallel.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;main
 ├── feature/billing   → 0004_add_invoices.py
 └── feature/auth      → 0004_add_sessions.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both are valid. Both are &lt;code&gt;0004&lt;/code&gt;. Merging produces a conflict where the numbering&lt;br&gt;
lies about order, and worse, the conflict is in the &lt;em&gt;filename&lt;/em&gt;, and git resolves it&lt;br&gt;
by keeping both, and now your migration history has two fourth steps.&lt;/p&gt;
&lt;p&gt;With hex IDs there is no collision. And if both branches set &lt;code&gt;down_revision&lt;/code&gt; to&lt;br&gt;
the same parent, Alembic detects a genuine branch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;$ alembic upgrade head
ERROR: Multiple head revisions are present
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It refuses to guess, and you resolve it explicitly with &lt;code&gt;alembic merge&lt;/code&gt;. The&lt;br&gt;
scary-looking IDs are what make that detection possible.&lt;/p&gt;
&lt;h4 id=&quot;if-you-want-readable-filenames&quot;&gt;If you want readable filenames&lt;/h4&gt;
&lt;p&gt;Add to &lt;code&gt;alembic.ini&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Giving &lt;code&gt;20260728_b7e2f81c4a90_add_spend_snapshots.py&lt;/code&gt;: date-sortable, revision&lt;br&gt;
ID still present because Alembic needs it. Existing files keep their names; only&lt;br&gt;
new ones pick it up.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-configuration-trap-that-cost-two-months&quot;&gt;The configuration trap that cost two months&lt;/h3&gt;
&lt;p&gt;This is the bug I most want to warn people about, because it produces &lt;strong&gt;no&lt;br&gt;
error message at all&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The default &lt;code&gt;env.py&lt;/code&gt; reads the database URL from &lt;code&gt;alembic.ini&lt;/code&gt;. FinOps Sentinel&lt;br&gt;
needed it configurable, so early on it did the obvious thing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# alembic/env.py: the version with the bug
def get_url():
    db_path = os.getenv(&quot;SENTINEL_DB_PATH&quot;, &quot;.sentinel.db&quot;)
    return f&quot;sqlite:///{db_path}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reasonable. Except the application reads the same setting through&lt;br&gt;
pydantic-settings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class Settings(BaseSettings):
    sentinel_db_path: str = &quot;.sentinel.db&quot;
    model_config = SettingsConfigDict(env_file=&quot;.env&quot;)   # ← reads .env too
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;os.getenv&lt;/code&gt; does not read &lt;code&gt;.env&lt;/code&gt;. pydantic-settings does.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The documented setup puts &lt;code&gt;SENTINEL_DB_PATH=data/sentinel.db&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, not in&lt;br&gt;
the shell. So:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Component &lt;/th&gt;&lt;th&gt;Reads from &lt;/th&gt;&lt;th&gt;Resolves to&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;The app &lt;/td&gt;&lt;td&gt;env var → &lt;code&gt;.env&lt;/code&gt; → default &lt;/td&gt;&lt;td&gt;&lt;code&gt;data/sentinel.db&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Alembic &lt;/td&gt;&lt;td&gt;env var → default &lt;/td&gt;&lt;td&gt;&lt;code&gt;.sentinel.db&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every migration ever run landed on &lt;code&gt;.sentinel.db&lt;/code&gt;. The application used&lt;br&gt;
&lt;code&gt;data/sentinel.db&lt;/code&gt;. Two files, silently, for two months.&lt;/p&gt;
&lt;h4 id=&quot;why-nothing-broke-for-so-long&quot;&gt;Why nothing broke for so long&lt;/h4&gt;
&lt;p&gt;The application also calls &lt;code&gt;Base.metadata.create_all()&lt;/code&gt; in tests, and the first&lt;br&gt;
three migrations only created tables that SQLAlchemy would have created anyway.&lt;br&gt;
The schemas &lt;em&gt;happened&lt;/em&gt; to agree.&lt;/p&gt;
&lt;p&gt;Then came a migration whose table nothing else creates: &lt;code&gt;spend_snapshots&lt;/code&gt;. The&lt;br&gt;
next scan died:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;alembic upgrade head
INFO  [alembic.runtime.migration] Running upgrade a1c9f4d27b13 -&amp;gt; b7e2f81c4a90

sentinel scan
OperationalError: (sqlite3.OperationalError) no such table: spend_snapshots
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alembic reported success. The app said the table didn't exist. Both were telling&lt;br&gt;
the truth about different files.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-one-owner-for-the-url&quot;&gt;The fix: one owner for the URL&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# config.py: the single source of truth
def database_url() -&amp;gt; str:
    &quot;&quot;&quot;The one place the findings database URL is built.

    Both the application (bootstrap.get_repository) and Alembic (alembic/env.py)
    call this. They used to build it independently, and drifted.
    &quot;&quot;&quot;
    return f&quot;sqlite:///{settings.sentinel_db_path}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# alembic/env.py
from finops_sentinel.config import database_url

def get_url():
    return database_url()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# bootstrap.py
def get_repository() -&amp;gt; FindingsRepository:
    return SqlAlchemyRepository(db_url=database_url())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And &lt;code&gt;alembic.ini&lt;/code&gt; is deliberately emptied:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;# Deliberately empty. env.py overrides this with the application's own
# Settings.sentinel_db_path. A URL here would be dead config that reads as
# authoritative, which is exactly how this drifted before.
sqlalchemy.url =
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Takeaway: if your app and your migrations resolve the database path&lt;br&gt;
separately, they will eventually disagree, and nothing will tell you.&lt;/strong&gt; Give the&lt;br&gt;
URL exactly one owner and have both call it.&lt;/p&gt;
&lt;p&gt;The project now pins this with tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_the_app_and_alembic_build_the_url_the_same_way():
    assert get_repository().db_url == database_url()

def test_alembic_ini_does_not_hardcode_a_database():
    parser = configparser.ConfigParser()
    parser.read(REPO_ROOT / &quot;alembic.ini&quot;)
    assert parser.get(&quot;alembic&quot;, &quot;sqlalchemy.url&quot;, fallback=&quot;&quot;).strip() == &quot;&quot;

def test_alembic_env_does_not_read_the_environment_itself():
    env_py = (REPO_ROOT / &quot;alembic&quot; / &quot;env.py&quot;).read_text()
    assert &quot;database_url&quot; in env_py
    assert &quot;os.getenv(&quot; not in env_py
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;autogenerate-and-where-to-stop-trusting-it&quot;&gt;Autogenerate, and where to stop trusting it&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;alembic revision --autogenerate -m &quot;add spend snapshots&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alembic compares your SQLAlchemy models against the live database and writes the&lt;br&gt;
diff. It needs &lt;code&gt;target_metadata&lt;/code&gt; wired up in &lt;code&gt;env.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from finops_sentinel.adapters.persistence.sqlalchemy_repo import Base
target_metadata = Base.metadata
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;It detects&lt;/strong&gt; new/dropped tables and columns, nullability changes, most index&lt;br&gt;
and unique-constraint changes, and some type changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It misses&lt;/strong&gt; table and column &lt;em&gt;renames&lt;/em&gt; (it sees a drop plus an add, and will&lt;br&gt;
happily destroy your data), CHECK constraint changes, server defaults in many&lt;br&gt;
cases, and anything requiring data transformation.&lt;/p&gt;
&lt;p&gt;The generated file is a &lt;strong&gt;draft&lt;/strong&gt;. The comment Alembic itself writes says so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def upgrade() -&amp;gt; None:
    # ### commands auto generated by Alembic - please adjust! ###
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always read it. For anything beyond adding a column, expect to hand-write.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;trap-sqlite-cannot-alter-a-constraint&quot;&gt;Trap: SQLite cannot ALTER a constraint&lt;/h3&gt;
&lt;p&gt;Adding two values to an enum required widening a CHECK constraint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class ResourceType(StrEnum):
    EBS_VOLUME = &quot;ebs_volume&quot;
    ELASTIC_IP = &quot;elastic_ip&quot;
    EC2_INSTANCE = &quot;ec2_instance&quot;
    EBS_SNAPSHOT = &quot;ebs_snapshot&quot;
    RDS_INSTANCE = &quot;rds_instance&quot;    # new
    S3_BUCKET = &quot;s3_bucket&quot;          # new
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Postgres: &lt;code&gt;ALTER TABLE ... DROP CONSTRAINT ... ADD CONSTRAINT ...&lt;/code&gt;. Done.&lt;/p&gt;
&lt;p&gt;On SQLite: &lt;strong&gt;there is no ALTER for constraints.&lt;/strong&gt; The only route is create a new&lt;br&gt;
table, copy the data, drop the old, rename the new. Alembic wraps this in&lt;br&gt;
&lt;code&gt;batch_alter_table&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with op.batch_alter_table(&quot;resources&quot;, recreate=&quot;always&quot;) as batch_op:
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;attempt-1-silently-kept-the-old-constraint&quot;&gt;Attempt 1: silently kept the old constraint&lt;/h4&gt;
&lt;p&gt;The first version passed &lt;code&gt;table_args&lt;/code&gt; and ran without error:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with op.batch_alter_table(
    &quot;resources&quot;,
    recreate=&quot;always&quot;,
    table_args=(
        sa.CheckConstraint(_resource_type_check(NEW_TYPES), name=&quot;check_resource_type&quot;),
    ),
):
    pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;alembic upgrade head&lt;/code&gt; printed success. Inserting an &lt;code&gt;rds_instance&lt;/code&gt; row still&lt;br&gt;
failed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; &lt;code&gt;table_args&lt;/code&gt; &lt;em&gt;adds to&lt;/em&gt; a reflected table definition rather than&lt;br&gt;
replacing it. Alembic reflected the live table, including the old four-value&lt;br&gt;
CHECK, then appended the new six-value one. The rebuilt table had &lt;strong&gt;both&lt;/strong&gt;, and&lt;br&gt;
the old one still rejected every new row.&lt;/p&gt;
&lt;p&gt;Only dumping the schema revealed it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sqlite3 data/sentinel.db &quot;.schema resources&quot;
# CONSTRAINT check_resource_type CHECK (resource_type IN ('ebs_volume', ...))   ← old
# CONSTRAINT check_resource_type CHECK (resource_type IN ('ebs_volume', ..., 's3_bucket'))
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;attempt-2-copy-from-to-suppress-reflection&quot;&gt;Attempt 2: &lt;code&gt;copy_from&lt;/code&gt; to suppress reflection&lt;/h4&gt;
&lt;p&gt;The fix is &lt;code&gt;copy_from&lt;/code&gt;, an explicit table definition that tells Alembic &lt;em&gt;not&lt;/em&gt;&lt;br&gt;
to reflect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _columns_only_resources() -&amp;gt; sa.Table:
    &quot;&quot;&quot;The `resources` table as columns and primary key, with NO constraints.

    Handed to batch_alter_table as `copy_from`, which suppresses reflection of
    the live table. That suppression is the point: `table_args` adds to a
    reflected definition rather than replacing it.
    &quot;&quot;&quot;
    return sa.Table(
        &quot;resources&quot;,
        sa.MetaData(),
        sa.Column(&quot;id&quot;, sa.String(), primary_key=True, nullable=False),
        sa.Column(&quot;resource_id&quot;, sa.String(), nullable=False),
        sa.Column(&quot;resource_type&quot;, sa.String(), nullable=False),
        sa.Column(&quot;resource_arn&quot;, sa.String(), nullable=False),
        sa.Column(&quot;region&quot;, sa.String(), nullable=False),
        sa.Column(&quot;current_tags&quot;, sa.String(), nullable=False),
        sa.Column(&quot;lifecycle&quot;, sa.String(), nullable=False),
        sa.Column(&quot;first_seen_at&quot;, sa.DateTime(timezone=True), nullable=False),
        sa.Column(&quot;last_seen_at&quot;, sa.DateTime(timezone=True), nullable=False),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;attempt-3-the-indexes-were-gone&quot;&gt;Attempt 3: the indexes were gone&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;copy_from&lt;/code&gt; suppresses &lt;strong&gt;index&lt;/strong&gt; reflection too. The rebuilt table lost its&lt;br&gt;
unique index on &lt;code&gt;resource_id&lt;/code&gt;, which the upsert logic depends on for&lt;br&gt;
correctness. So it gets recreated by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _rebuild_resources(resource_types: tuple[str, ...]) -&amp;gt; None:
    with op.batch_alter_table(
        &quot;resources&quot;,
        recreate=&quot;always&quot;,
        copy_from=_columns_only_resources(),
        table_args=(
            sa.CheckConstraint(&quot;lifecycle IN ('active', 'deleted')&quot;,
                               name=&quot;check_resource_lifecycle&quot;),
            sa.CheckConstraint(_resource_type_check(resource_types),
                               name=&quot;check_resource_type&quot;),
        ),
    ):
        pass  # the rebuild itself is the migration

    # copy_from also suppresses index reflection, so re-create by hand.
    bind = op.get_bind()
    existing = {index[&quot;name&quot;] for index in sa.inspect(bind).get_indexes(&quot;resources&quot;)}
    if &quot;ix_resources_resource_id&quot; not in existing:
        op.create_index(op.f(&quot;ix_resources_resource_id&quot;), &quot;resources&quot;,
                        [&quot;resource_id&quot;], unique=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Rule for SQLite batch migrations: whatever the batch block omits is silently&lt;br&gt;
absent from the rebuilt table.&lt;/strong&gt; Every column, every constraint, every index has&lt;br&gt;
to be re-declared. And &quot;it ran without error&quot; proves nothing. Dump the schema.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;downgrades-that-refuse-to-run&quot;&gt;Downgrades that refuse to run&lt;/h3&gt;
&lt;p&gt;Most &lt;code&gt;downgrade()&lt;/code&gt; functions are written on autopilot as the inverse of&lt;br&gt;
&lt;code&gt;upgrade()&lt;/code&gt;. Sometimes the honest inverse is &lt;em&gt;refusing&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def downgrade() -&amp;gt; None:
    bind = op.get_bind()
    stranded = bind.execute(
        sa.text(&quot;SELECT COUNT(*) FROM resources &quot;
                &quot;WHERE resource_type IN ('rds_instance', 's3_bucket')&quot;)
    ).scalar_one()
    if stranded:
        raise RuntimeError(
            f&quot;Cannot downgrade: {stranded} resource(s) use rds_instance or s3_bucket, &quot;
            &quot;which the previous CHECK constraint forbids. Delete those resources and &quot;
            &quot;the findings referencing them first if this downgrade is intended.&quot;
        )
    _rebuild_resources(OLD_RESOURCE_TYPES)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Narrowing the constraint with those rows present means deleting them, which&lt;br&gt;
also orphans every finding referencing them. A downgrade that silently destroys&lt;br&gt;
data to succeed is worse than one that stops and explains.&lt;/p&gt;
&lt;p&gt;The docstring says so up front, so nobody is surprised at 2am:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;downgrade()&lt;/code&gt; restores the four-value constraint and will FAIL if any&lt;br&gt;
rds_instance or s3_bucket row exists by then, deliberately.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-simple-migration-for-contrast&quot;&gt;A simple migration, for contrast&lt;/h3&gt;
&lt;p&gt;Not every migration is a fight. Adding a table is exactly what you'd hope:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def upgrade() -&amp;gt; None:
    op.create_table(
        &quot;spend_snapshots&quot;,
        sa.Column(&quot;id&quot;, sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
        sa.Column(&quot;snapshot_date&quot;, sa.Date(), nullable=False),
        # Stored as a string, matching SafeNumeric: SQLite has no Decimal, and
        # a float column would quietly round money.
        sa.Column(&quot;total_estimated_monthly_usd&quot;, sa.String(), nullable=False),
        sa.Column(&quot;open_findings&quot;, sa.Integer(), nullable=False),
        sa.Column(&quot;active_resources&quot;, sa.Integer(), nullable=False),
        sa.Column(&quot;captured_at&quot;, sa.DateTime(timezone=True), nullable=False),
    )
    op.create_index(
        op.f(&quot;ix_spend_snapshots_snapshot_date&quot;),
        &quot;spend_snapshots&quot;, [&quot;snapshot_date&quot;], unique=True,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the docstring convention this project uses: the &lt;em&gt;why&lt;/em&gt;, not the &lt;em&gt;what&lt;/em&gt;:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;snapshot_date&lt;/code&gt; is UNIQUE on purpose. The repository upserts by date, so two&lt;br&gt;
scans on the same day collapse to one row. Otherwise a day that happened to&lt;br&gt;
be scanned five times would weigh five times as much in the trailing mean, and&lt;br&gt;
the z-score would measure scan cadence rather than spend.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Six months later, that paragraph is worth more than the code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;keeping-constraints-from-drifting&quot;&gt;Keeping constraints from drifting&lt;/h3&gt;
&lt;p&gt;CHECK constraints duplicating enum values is a drift hazard. This project&lt;br&gt;
generates them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _in_clause(column: str, values: type[StrEnum]) -&amp;gt; str:
    &quot;&quot;&quot;Render `column IN ('a', 'b', ...)` straight from a StrEnum.

    Generated rather than hand-written so the constraint cannot drift from the
    enum: adding a ResourceType now updates this automatically, and only the
    Alembic migration has to be written by hand.
    &quot;&quot;&quot;
    members = &quot;, &quot;.join(f&quot;'{member.value}'&quot; for member in values)
    return f&quot;{column} IN ({members})&quot;


class ResourceModel(Base):
    __tablename__ = &quot;resources&quot;
    ...
    __table_args__ = (
        CheckConstraint(_in_clause(&quot;lifecycle&quot;, ResourceLifecycle),
                        name=&quot;check_resource_lifecycle&quot;),
        CheckConstraint(_in_clause(&quot;resource_type&quot;, ResourceType),
                        name=&quot;check_resource_type&quot;),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The model side can never drift from the enum. Only the migration is&lt;br&gt;
hand-written, which is unavoidable, since a migration must describe a &lt;em&gt;transition&lt;/em&gt; between&lt;br&gt;
two states, and only one of them exists in your code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;testing-migrations&quot;&gt;Testing migrations&lt;/h3&gt;
&lt;p&gt;A green &lt;code&gt;alembic upgrade head&lt;/code&gt; proves almost nothing (see attempt 1). Two things&lt;br&gt;
worth doing:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Round-trip against a copy of a real database.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cp data/sentinel.db /tmp/mig-test.db
SENTINEL_DB_PATH=/tmp/mig-test.db alembic upgrade head
SENTINEL_DB_PATH=/tmp/mig-test.db alembic downgrade -1
SENTINEL_DB_PATH=/tmp/mig-test.db alembic upgrade head

sqlite3 /tmp/mig-test.db &quot;select count(*) from resources; select count(*) from findings;&quot;
# 274 / 187, same as before
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Real data, real row counts, and it catches data loss a fresh database never&lt;br&gt;
would.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Assert the error message is useful.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;An un-migrated database should not produce a raw SQL traceback:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def upsert_resource(self, resource: Resource) -&amp;gt; None:
    try:
        self._upsert_resource(resource)
    except IntegrityError as exc:
        if &quot;check_resource_type&quot; not in str(exc):
            raise
        raise RuntimeError(
            f&quot;The findings database does not allow resource type &quot;
            f&quot;'{resource.resource_type}'. It was created by an older &quot;
            f&quot;version of FinOps Sentinel. Run `alembic upgrade head` &quot;
            f&quot;against {self.db_url} and scan again.&quot;
        ) from exc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Failing hard is right: a half-written inventory is worse than a stopped run.&lt;br&gt;
But the traceback told nobody what to do. Now it names the type, the database,&lt;br&gt;
and the command.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;command-reference&quot;&gt;Command reference&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;alembic revision -m &quot;message&quot;                  # empty migration, hand-written
alembic revision --autogenerate -m &quot;message&quot;   # diff models vs database

alembic upgrade head          # apply everything outstanding
alembic upgrade +1            # one step forward
alembic downgrade -1          # one step back
alembic downgrade base        # all the way down

alembic current               # what revision is this database at?
alembic history --verbose     # the full chain
alembic heads                 # &amp;gt;1 means you have a branch to merge
alembic merge -m &quot;merge&quot; a1b2 c3d4

alembic upgrade head --sql    # print SQL instead of executing (offline mode)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;alembic current&lt;/code&gt; is the one to reach for when something is confusing. It tells&lt;br&gt;
you where Alembic thinks the database is, and if that disagrees with the&lt;br&gt;
database you &lt;em&gt;expect&lt;/em&gt;, you have the bug from earlier in this post.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;checklist&quot;&gt;Checklist&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never edit a migration that has been applied anywhere but your laptop.&lt;/strong&gt;&lt;br&gt;
Write a new one. The old revision ID is a foreign key in every database that&lt;br&gt;
ran it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read autogenerated migrations before committing.&lt;/strong&gt; Especially for renames,&lt;br&gt;
which autogenerate turns into drop-plus-add.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write the &quot;why&quot; in the docstring.&lt;/strong&gt; The SQL says what; only you can say why.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test on a copy of real data.&lt;/strong&gt; Fresh databases hide data-loss bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dump the schema after non-trivial migrations.&lt;/strong&gt; &quot;It ran&quot; is not &quot;it worked.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give the database URL one owner&lt;/strong&gt; that both the app and Alembic call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make downgrades honest&lt;/strong&gt;, including refusing when the honest inverse is&lt;br&gt;
data loss.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Migrations are the one part of a codebase where a mistake is not just wrong but&lt;br&gt;
&lt;em&gt;permanently&lt;/em&gt; wrong, replicated to every environment that ran it. Worth the&lt;br&gt;
extra ten minutes.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Every example here is from &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/boazleleina/finops-sentinel&quot;&gt;github.com/boazleleina/finops-sentinel&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26691/alembic-in-practice-structure-revisions-and-the-traps-sqlite-sets</guid>
<pubDate>Thu, 10 Sep 2026 03:50:15 +0000</pubDate>
</item>
<item>
<title>Building a Real-Time Dashboard with FastAPI, WebSockets, and MySQL</title>
<link>https://coderlegion.com/26578/building-a-real-time-dashboard-with-fastapi-websockets-and-mysql</link>
<description>&lt;p&gt;I recently worked on a lightweight real-time dashboard built with &lt;strong&gt;FastAPI&lt;/strong&gt;, &lt;strong&gt;WebSockets&lt;/strong&gt;, and &lt;strong&gt;aiomysql&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The main goal was simple: retrieve live data from a MySQL database and push updates to the frontend in real time without relying on continuous polling.&lt;/p&gt;
&lt;p&gt;This project provided a practical opportunity to explore asynchronous programming, WebSocket communication, and database integration using Python.&lt;/p&gt;
&lt;h3 id=&quot;project-overview&quot;&gt;Project Overview&lt;/h3&gt;
&lt;p&gt;The application uses a &lt;strong&gt;FastAPI backend&lt;/strong&gt; to communicate with a MySQL database and a &lt;strong&gt;WebSocket endpoint&lt;/strong&gt; to deliver database updates to connected clients.&lt;/p&gt;
&lt;p&gt;The overall architecture can be summarized as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MySQL Database
      │
      ▼
   aiomysql
      │
      ▼
FastAPI Backend
      │
      ▼
  WebSocket
      │
      ▼
Frontend Dashboard
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The WebSocket connection allows the server to push new data directly to the client, making the dashboard capable of displaying updates in real time.&lt;/p&gt;
&lt;h3 id=&quot;what-this-project-demonstrates&quot;&gt;What This Project Demonstrates&lt;/h3&gt;
&lt;p&gt;The project covers several important backend development concepts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Setting up Python and the required dependencies&lt;/li&gt;
&lt;li&gt;Building an asynchronous FastAPI backend&lt;/li&gt;
&lt;li&gt;Connecting to MySQL using &lt;code&gt;aiomysql&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Implementing a WebSocket endpoint&lt;/li&gt;
&lt;li&gt;Retrieving database records asynchronously&lt;/li&gt;
&lt;li&gt;Sending live data to connected clients&lt;/li&gt;
&lt;li&gt;Running the application with Uvicorn&lt;/li&gt;
&lt;li&gt;Structuring a simple real-time backend application&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-use-websockets-&quot;&gt;Why Use WebSockets?&lt;/h3&gt;
&lt;p&gt;Traditional REST APIs generally follow a request/response model.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client → Request → Server
Client ← Response ← Server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the frontend needs updated information continuously, it may have to repeatedly send requests to the server.&lt;/p&gt;
&lt;p&gt;This is commonly known as &lt;strong&gt;polling&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client → Request
Server → Response

Client → Request
Server → Response

Client → Request
Server → Response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With WebSockets, the client establishes a persistent connection with the server:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client ⇄ WebSocket Connection ⇄ Server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The server can then push new information to the client whenever it becomes available.&lt;/p&gt;
&lt;p&gt;This makes WebSockets particularly useful for applications where data needs to be delivered quickly and continuously.&lt;/p&gt;
&lt;h3 id=&quot;potential-use-cases&quot;&gt;Potential Use Cases&lt;/h3&gt;
&lt;p&gt;This architecture can be useful for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Real-time dashboards&lt;/li&gt;
&lt;li&gt;Monitoring systems&lt;/li&gt;
&lt;li&gt;Live administration panels&lt;/li&gt;
&lt;li&gt;Student management systems&lt;/li&gt;
&lt;li&gt;Record management applications&lt;/li&gt;
&lt;li&gt;System status monitoring&lt;/li&gt;
&lt;li&gt;Live data visualization&lt;/li&gt;
&lt;li&gt;Notification systems&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;documentation-and-source-code&quot;&gt;Documentation and Source Code&lt;/h3&gt;
&lt;p&gt;I created a complete guide covering the setup and implementation process, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Python installation&lt;/li&gt;
&lt;li&gt;Required package installation&lt;/li&gt;
&lt;li&gt;Project configuration&lt;/li&gt;
&lt;li&gt;Server startup&lt;/li&gt;
&lt;li&gt;Complete &lt;code&gt;main.py&lt;/code&gt; implementation&lt;/li&gt;
&lt;li&gt;FastAPI and WebSocket configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The GitHub repository also includes the documentation and source code for reference.&lt;/p&gt;
&lt;h4 id=&quot;github-repository&quot;&gt;GitHub Repository&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/NithilaLD/fastapi-websocket-realtime-dashboard&quot;&gt;FastAPI WebSocket Real-Time Dashboard - GitHub Repository&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The repository contains:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Complete Guide (PDF)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Source Code (&lt;code&gt;main.py&lt;/code&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;key-technologies&quot;&gt;Key Technologies&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Technology &lt;/th&gt;&lt;th&gt;Purpose&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Python     &lt;/td&gt;&lt;td&gt;Backend programming&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;FastAPI    &lt;/td&gt;&lt;td&gt;Asynchronous web framework&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;WebSockets &lt;/td&gt;&lt;td&gt;Real-time client-server communication&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;aiomysql   &lt;/td&gt;&lt;td&gt;Asynchronous MySQL connectivity&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;MySQL      &lt;/td&gt;&lt;td&gt;Database&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Uvicorn    &lt;/td&gt;&lt;td&gt;ASGI server&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;final-thoughts-2&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;This project was a useful exercise in combining &lt;strong&gt;asynchronous programming, database connectivity, and real-time communication&lt;/strong&gt; into a practical backend application.&lt;/p&gt;
&lt;p&gt;FastAPI and WebSockets provide a straightforward foundation for building applications where data needs to move between the server and client with minimal delay.&lt;/p&gt;
&lt;p&gt;I look forward to extending this project with features such as authentication, multiple connected clients, automatic database change detection, improved error handling, and a more advanced frontend dashboard.&lt;/p&gt;
&lt;p&gt;If you are working with FastAPI or real-time applications, I'd be interested to hear about your projects and approaches.&lt;/p&gt;
&lt;h3 id=&quot;also-published-on&quot;&gt;Also Published On&lt;/h3&gt;
&lt;p&gt;This article is also available on the following platforms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/pulse/building-real-time-fastapi-dashboard-websockets-async-liyanarachchi-nvf7c/&quot;&gt;Read on LinkedIn&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://dulannithilaliyanarachchi.medium.com/building-a-real-time-fastapi-dashboard-with-websockets-and-async-mysql-344d86522a0e&quot;&gt;Read on Medium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://builder.aws.com/content/3Ij3xiZllF1UvwYpk5OCYx52UfK/building-a-real-time-fastapi-dashboard-with-websockets-and-async-mysql&quot;&gt;Read on AWS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DEV Community:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/dulannithilaliyanarachchi/building-a-real-time-dashboard-with-fastapi-websockets-and-mysql-1494&quot;&gt;Read on DEV Community&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26578/building-a-real-time-dashboard-with-fastapi-websockets-and-mysql</guid>
<pubDate>Wed, 09 Sep 2026 08:42:41 +0000</pubDate>
</item>
<item>
<title>Building database synchronization the boring way</title>
<link>https://coderlegion.com/26522/building-database-synchronization-the-boring-way</link>
<description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://iamroot.tech/&quot;&gt;iamroot.tech&lt;/a&gt; uses a custom-built, asynchronous MySQL database synchronization system handling ~10 million daily changes across several distributed nodes without consensus algorithms or distributed coordinators.&lt;br&gt;
Key Architecture &amp;amp; Trade-offs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Primary Keys: UUIDs to allow conflict-free row generation on any node. &lt;/li&gt;
&lt;li&gt;Change Tracking: Database triggers log INSERT, UPDATE, and DELETE operations into a simplified change log. &lt;/li&gt;
&lt;li&gt;Replication Mechanism: Asynchronous background workers process log entries.&lt;/li&gt;
&lt;li&gt;Conflict Avoidance: Application-level single-writer authority per dataset &lt;/li&gt;
&lt;li&gt;Fault Tolerance: Nodes catch up automatically after offline periods by replaying the simple change log backlog.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-setup---servers-will-disappear&quot;&gt;The setup - servers will disappear&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://iamroot.tech/&quot;&gt;iamroot.tech&lt;/a&gt; runs across numerous servers in different geographical locations. They are deliberately inexpensive machines, and an important assumption behind the entire architecture is that individual servers will - at some point in time - disappear, underperform or otherwise misbehave.&lt;/p&gt;
&lt;p&gt;The goal has never been to make every server perfectly reliable. The goal is to make each server failure completely uninteresting.&lt;/p&gt;
&lt;p&gt;That creates an interesting problem for the databases. A server needs access to shared data, but I don't want a write on server A to depend on server B being available. In fact, I don't really want server A to care about server B at all while serving a request.&lt;/p&gt;
&lt;p&gt;So I ended up building my own synchronization system. It currently keeps roughly 50–60 tables synchronized across numerous servers and handles around 10 million changes on a normal day.&lt;/p&gt;
&lt;p&gt;It isn't particularly clever. In fact, it's about as simple as it gets.&lt;/p&gt;
&lt;p&gt;And that is rather the point.&lt;/p&gt;
&lt;h3 id=&quot;a-failing-server-should-create-a-backlog-not-an-outage&quot;&gt;A failing server should create a backlog, not an outage&lt;/h3&gt;
&lt;p&gt;The most important principle is that database writes are local operations. If the application running on server A needs to update something, it writes to the local MySQL database and continues.&lt;/p&gt;
&lt;p&gt;Synchronization happens afterwards.&lt;/p&gt;
&lt;p&gt;If server B happens to be slow, offline or has been gone for three days, that doesn't change what happens on A. It simply means B has more catching up to do when it eventually returns.&lt;/p&gt;
&lt;p&gt;In practice, this gives iamroot.tech a distributed data architecture without trying to turn MySQL itself into a distributed database. Each server owns its local database and can operate independently, while relevant data is eventually synchronized between them.&lt;/p&gt;
&lt;p&gt;Temporary differences between nodes are therefore expected. Eventual consistency isn't something the architecture reluctantly tolerates; it is part of the design.&lt;/p&gt;
&lt;p&gt;That trade-off works because the application itself understands how the data is used.&lt;/p&gt;
&lt;h3 id=&quot;the-implementation-is-almost-embarrassingly-simple&quot;&gt;The implementation is almost embarrassingly simple&lt;/h3&gt;
&lt;p&gt;Tables that need to be shared use UUIDs as their primary keys. That allows records to be created independently on different servers without coordinating ID ranges or worrying about primary key collisions later.&lt;/p&gt;
&lt;p&gt;Each server then maintains its own local change log. Synchronized tables have triggers for INSERT, UPDATE and DELETE, and whenever something changes, a small entry containing the table, record ID, operation and timestamp is written to the log.&lt;/p&gt;
&lt;p&gt;A background service processes those entries and sends the changes to whichever nodes should receive them. There is no central replication queue and no synchronization master.&lt;/p&gt;
&lt;p&gt; For inserts and updates, I use MySQL's INSERT ... ON DUPLICATE KEY UPDATE. If a server somehow missed the original INSERT but later receives an UPDATE, the record simply gets created anyway.&lt;/p&gt;
&lt;p&gt;Replicated writes bypass the change-log triggers for that database connection, while normal application writes still trigger them. Otherwise A would update B, B would send the same change back to A, and my wonderfully simple synchronization system would become a wonderfully effective infinite loop.&lt;/p&gt;
&lt;p&gt;DELETEs work through the same mechanism. The DELETE entry stays in the change log until the relevant nodes have processed it, effectively making it a temporary replication tombstone. A server can therefore disappear for days and still learn which records were deleted while it was gone.&lt;/p&gt;
&lt;p&gt;That's basically it.&lt;/p&gt;
&lt;h3 id=&quot;avoiding-problems-instead-of-solving-them&quot;&gt;Avoiding problems instead of solving them&lt;/h3&gt;
&lt;p&gt;This is not conflict-safe multi-master replication.&lt;/p&gt;
&lt;p&gt;If two servers independently modify the same record at exactly the wrong moment, there is no clever distributed algorithm deciding which version represents universal truth.&lt;/p&gt;
&lt;p&gt;Instead, I largely avoid the problem at the application level.&lt;/p&gt;
&lt;p&gt;Individual records normally have one logical writer - one server or subsystem that is effectively king for that data. The code working with those records knows about that architecture.&lt;/p&gt;
&lt;p&gt;That is an important distinction. I could add version vectors, distributed locking, automatic conflict resolution and considerably more machinery. Those are perfectly valid solutions to problems other systems have.&lt;/p&gt;
&lt;p&gt;But if I can structure the application so the problem doesn't normally exist, I would rather do that.&lt;/p&gt;
&lt;p&gt;The same philosophy applies elsewhere. Synchronization is at-least-once rather than exactly-once. Duplicate work happens, but the operations are idempotent, so that is mostly harmless. The change log isn't an event store either. If a record changes ten times, I don't need to reconstruct all ten historical versions. I care about getting the current state where it needs to go.&lt;/p&gt;
&lt;h3 id=&quot;recovery-is-deliberately-boring&quot;&gt;Recovery is deliberately boring&lt;/h3&gt;
&lt;p&gt;For every table and destination node, the synchronizer remembers how far through the local change log that node has progressed.&lt;/p&gt;
&lt;p&gt;Important data is typically synchronized every 10 – 20 seconds, while less important tables may only be processed every ten minutes. If a destination disappears, its position simply stops advancing while everybody else continues.&lt;/p&gt;
&lt;p&gt;When it returns, there is normally nothing for me to do. The synchronizer knows where it stopped and continues from there. Once all relevant nodes have moved beyond old change-log entries, those entries can be removed.&lt;/p&gt;
&lt;p&gt;This isn't just how I hope it works. Servers have disappeared and returned plenty of times in production.&lt;/p&gt;
&lt;p&gt;The recovery procedure is dead simple: sit back and wait.&lt;/p&gt;
&lt;p&gt;I like recovery procedures like that.&lt;/p&gt;
&lt;h3 id=&quot;why-not-just-use-mysql-replication-&quot;&gt;Why not just use MySQL replication?&lt;/h3&gt;
&lt;p&gt;MySQL already provides very capable replication, and this isn't an attempt to build a better replacement.&lt;/p&gt;
&lt;p&gt;I wanted slightly different properties.&lt;/p&gt;
&lt;p&gt;Not every table needs to exist everywhere. Different data can have different synchronization intervals and destinations. More importantly, replication isn't part of the availability model for an individual application server. A slow destination creates work for the synchronizer, not latency for the application serving a user.&lt;/p&gt;
&lt;p&gt;There are obvious trade-offs. The synchronizer doesn't automatically understand referential integrity between tables, although I can control their processing order. It doesn't negotiate schemas either; keeping compatible schemas across the relevant servers is my responsibility.&lt;/p&gt;
&lt;p&gt;Those are not necessarily features waiting to be implemented. They are boundaries I know about and can design around.&lt;/p&gt;
&lt;h3 id=&quot;boring-as-a-feature&quot;&gt;Boring as a feature&lt;/h3&gt;
&lt;p&gt;Around 10 million changes pass through this setup every day, with the ASN database alone sometimes generating several million updates during a processing run.&lt;/p&gt;
&lt;p&gt;Could I make the synchronization system considerably smarter? Absolutely.&lt;/p&gt;
&lt;p&gt;But I didn't set out to build a distributed database. I wanted a multitude of servers that could operate independently, share the data they needed, tolerate each other disappearing, and recover without me getting involved.&lt;/p&gt;
&lt;p&gt;The synchronization system is simply what fell out of those requirements.&lt;/p&gt;
&lt;p&gt;There are plenty of situations where this design would be completely inappropriate. There are also plenty where sophisticated distributed database technology solves very real problems.&lt;/p&gt;
&lt;p&gt;But complexity has a real cost.&lt;/p&gt;
&lt;p&gt;For iamroot.tech, I'd rather have infrastructure I can explain on a single piece of paper, predict when something breaks, and largely leave alone when a server disappears.&lt;/p&gt;
&lt;p&gt;Extremely simple, asynchronous and a tad inefficient in the actual operations, yes - but very efficient at solving the problem, very predictable in the way it works, and - most importantly - it does not require any human intervention at any point.&lt;/p&gt;
&lt;p&gt;That is all I need.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26522/building-database-synchronization-the-boring-way</guid>
<pubDate>Tue, 08 Sep 2026 21:13:56 +0000</pubDate>
</item>
<item>
<title>How I Structured My Firestore Data Model for an Anti-Proxy Attendance System</title>
<link>https://coderlegion.com/26120/how-i-structured-my-firestore-data-model-for-an-anti-proxy-attendance-system</link>
<description>&lt;p&gt;The database architecture behind students, meetings, attendance, authentication, and controlling unnecessary Firestore reads.*&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-i-structured-my-firestore-data-model-for-an-anti-proxy-attendance-system&quot;&gt;How I Structured My Firestore Data Model for an Anti-Proxy Attendance System&lt;/h2&gt;
&lt;p&gt;When I began building my attendance system, database architecture wasn't really my first concern.&lt;/p&gt;
&lt;p&gt;I had one straightforward objective:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Make the MVP work.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I needed a place to store students.&lt;/p&gt;
&lt;p&gt;I needed a way to create meetings.&lt;/p&gt;
&lt;p&gt;I needed to save attendance records.&lt;/p&gt;
&lt;p&gt;So I started creating Firestore data structures based on whatever the application needed at that moment.&lt;/p&gt;
&lt;p&gt;Initially, that approach worked.&lt;/p&gt;
&lt;p&gt;Then the application started expanding.&lt;/p&gt;
&lt;p&gt;Authentication was introduced.&lt;/p&gt;
&lt;p&gt;Dynamic QR authentication was introduced.&lt;/p&gt;
&lt;p&gt;Device verification was introduced.&lt;/p&gt;
&lt;p&gt;Attendance history was introduced.&lt;/p&gt;
&lt;p&gt;Offline synchronization was introduced.&lt;/p&gt;
&lt;p&gt;And suddenly, Firestore was doing much more than simply holding attendance records.&lt;/p&gt;
&lt;p&gt;It was turning into the foundation that the rest of the application depended on.&lt;/p&gt;
&lt;p&gt;That's when an important realization hit me:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;A schema that is perfectly fine for an MVP may not remain suitable once the application starts growing.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-first-question-what-really-needs-to-live-in-the-database-&quot;&gt;The First Question: What Really Needs to Live in the Database?&lt;/h2&gt;
&lt;p&gt;An attendance system sounds straightforward until you actually list everything it needs to keep track of.&lt;/p&gt;
&lt;p&gt;For every attendance event, I may need details such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The student&lt;/li&gt;
&lt;li&gt;The meeting&lt;/li&gt;
&lt;li&gt;The attendance status&lt;/li&gt;
&lt;li&gt;The time the attendance was recorded&lt;/li&gt;
&lt;li&gt;Authentication information&lt;/li&gt;
&lt;li&gt;Device information&lt;/li&gt;
&lt;li&gt;Synchronization state&lt;/li&gt;
&lt;li&gt;Administrative information&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The difficult part wasn't simply finding a place to store all of these things.&lt;/p&gt;
&lt;p&gt;The harder problem was determining &lt;strong&gt;how all of these pieces should connect with one another&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I started looking at the database through the entities that actually existed inside the system.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Student
   │
   ├── Identity
   ├── Device
   └── Attendance
          │
          ▼
        Meeting
          │
          ├── QR Session
          └── Attendance Records
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The database needed to represent these relationships without making the application repeatedly retrieve the same information.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;firestore-forces-a-different-way-of-thinking&quot;&gt;Firestore Forces a Different Way of Thinking&lt;/h2&gt;
&lt;p&gt;Firestore doesn't work like a traditional relational database.&lt;/p&gt;
&lt;p&gt;There are no conventional SQL joins where I can simply write something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ...
FROM students
JOIN attendance ...
JOIN meetings ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead, I need to think about a different question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Which pieces of data does the application need to access together?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That question became one of the most important considerations while designing the schema.&lt;/p&gt;
&lt;p&gt;In Firestore, the structure of your data directly influences how much information the application has to retrieve.&lt;/p&gt;
&lt;p&gt;And I had already experienced the consequences of getting that wrong.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;i-still-remember-the-50-000-read-problem&quot;&gt;I Still Remember the 50,000 Read Problem&lt;/h2&gt;
&lt;p&gt;Earlier during development, I accidentally burned through my Firestore free-tier reads while testing and debugging the application.&lt;/p&gt;
&lt;p&gt;That experience completely changed the way I looked at database operations.&lt;/p&gt;
&lt;p&gt;Before that happened, my thinking was basically:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;It's just one additional read.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Afterwards, my question became:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;Does the application actually need this read?&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That shift in mindset started influencing the database architecture as the project continued to evolve.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;designing-around-actual-access-patterns&quot;&gt;Designing Around Actual Access Patterns&lt;/h2&gt;
&lt;p&gt;Rather than beginning with:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Which collections should I create?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I started approaching the problem from the opposite direction:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;What information does the application actually need to retrieve?&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For example, when an attendance session is running, the system needs enough information to validate the request and then record the attendance correctly.&lt;/p&gt;
&lt;p&gt;So the database has to support operations such as:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Create Meeting
      ↓
Generate Attendance Session
      ↓
Validate Student
      ↓
Validate Device
      ↓
Validate QR
      ↓
Record Attendance
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The database should not become the thing slowing down every stage of the process.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;keeping-attendance-as-a-separate-record&quot;&gt;Keeping Attendance as a Separate Record&lt;/h2&gt;
&lt;p&gt;One of the key decisions I made was to treat attendance as its own record rather than repeatedly changing a student's profile whenever they attend a meeting.&lt;/p&gt;
&lt;p&gt;Conceptually, the relationship looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Student
   │
   └──────────────┐
                  │
                  ▼
              Attendance
                  │
                  ▼
                Meeting
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This allows attendance history to exist separately from the student's core profile.&lt;/p&gt;
&lt;p&gt;Instead of only asking:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Is this student present?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;the system can answer a much more useful question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Was this student present for this particular meeting, and when exactly was that attendance recorded?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That distinction is important.&lt;/p&gt;
&lt;p&gt;Attendance represents an &lt;strong&gt;event&lt;/strong&gt;, rather than simply being a permanent property attached to a student.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;more-data-does-not-automatically-mean-a-better-database&quot;&gt;More Data Does Not Automatically Mean a Better Database&lt;/h2&gt;
&lt;p&gt;A common trap during database design is duplicating information everywhere because it makes individual queries feel easier.&lt;/p&gt;
&lt;p&gt;At first, that can seem convenient.&lt;/p&gt;
&lt;p&gt;The problem appears later when those duplicated values need to stay consistent.&lt;/p&gt;
&lt;p&gt;For example, copying the same information into several documents may simplify one specific query, but now the application has to maintain multiple versions of the same data.&lt;/p&gt;
&lt;p&gt;So I constantly found myself balancing:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fast reads&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;against&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Data duplication&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;against&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write complexity&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;against&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Firestore usage&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There isn't one universal answer that solves all of these concerns.&lt;/p&gt;
&lt;p&gt;The right decision depends on how the application actually reads and writes the data.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-an-attendance-request-actually-contains&quot;&gt;What an Attendance Request Actually Contains&lt;/h2&gt;
&lt;p&gt;At a high level, an attendance request now carries several different pieces of context.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Attendance Request
       │
       ├── Student Identity
       │
       ├── Meeting
       │
       ├── QR Validation
       │
       ├── Device Verification
       │
       └── Attendance Record
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So attendance isn't really just:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;student → present
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's closer to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;student
   +
meeting
   +
valid session
   +
trusted device
   +
validated request
   =
attendance
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That means the database is not merely a storage layer.&lt;/p&gt;
&lt;p&gt;It becomes part of the security architecture as well.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;thinking-carefully-about-reads-and-writes&quot;&gt;Thinking Carefully About Reads and Writes&lt;/h2&gt;
&lt;p&gt;After experiencing the 50,000-read issue, I started paying much more attention to the difference between reads and writes.&lt;/p&gt;
&lt;p&gt;A system can function correctly and still perform far more database operations than it actually needs.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Page Load
   ↓
Read Student
   ↓
Read Meeting
   ↓
Read Configuration
   ↓
Read Student Again
   ↓
Read Meeting Again
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing is technically failing here.&lt;/p&gt;
&lt;p&gt;But the application is doing unnecessary work.&lt;/p&gt;
&lt;p&gt;So I started looking for ways to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reuse information that the application already has&lt;/li&gt;
&lt;li&gt;Eliminate repeated reads&lt;/li&gt;
&lt;li&gt;Cache frequently accessed information&lt;/li&gt;
&lt;li&gt;Minimize unnecessary writes&lt;/li&gt;
&lt;li&gt;Fetch only the data that is actually required&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This eventually became part of the larger optimization work I covered in Part 3.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;firestore-security-goes-beyond-security-rules&quot;&gt;Firestore Security Goes Beyond Security Rules&lt;/h2&gt;
&lt;p&gt;Another lesson from the project was that database security cannot be reduced to Firestore Security Rules alone.&lt;/p&gt;
&lt;p&gt;Rules are important.&lt;/p&gt;
&lt;p&gt;But they represent only one layer of the overall security model.&lt;/p&gt;
&lt;p&gt;In my attendance system, validation happens across several stages:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User Authentication
        ↓
Dynamic QR Validation
        ↓
Device Verification
        ↓
Backend Validation
        ↓
Firestore
        ↓
Attendance Record
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The database should never blindly trust whatever information the frontend submits.&lt;/p&gt;
&lt;p&gt;The backend needs to validate the request before writing sensitive attendance information.&lt;/p&gt;
&lt;p&gt;This separation became even more important as more anti-proxy mechanisms were introduced into the system.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;offline-support-changes-the-data-lifecycle&quot;&gt;Offline Support Changes the Data Lifecycle&lt;/h2&gt;
&lt;p&gt;Then another challenge appeared.&lt;/p&gt;
&lt;p&gt;Offline support.&lt;/p&gt;
&lt;p&gt;Once the system could record attendance without immediately communicating with Firestore, the data model needed to distinguish between two different states:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Attendance created locally
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Attendance successfully synchronized
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Conceptually, the lifecycle became:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Student
   ↓
Attendance Event
   ↓
Local Storage
   ↓
Pending Sync
   ↓
Firestore
   ↓
Synchronized
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That meant the lifecycle of an attendance record was no longer simply:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Create → Save
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It became:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Create
  ↓
Store locally
  ↓
Wait
  ↓
Synchronize
  ↓
Confirm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That changed the way I thought about reliability as well.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-i-would-avoid-doing&quot;&gt;What I Would Avoid Doing&lt;/h2&gt;
&lt;p&gt;One of the clearest lessons from this project is that Firestore schemas should not be designed by blindly copying relational database patterns.&lt;/p&gt;
&lt;p&gt;Firestore has its own strengths, limitations, and way of handling data.&lt;/p&gt;
&lt;p&gt;Instead of asking:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;How would I structure this in MySQL?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I found it much more useful to ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;What are the operations my application performs most often?&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then I could design the data model around those access patterns.&lt;/p&gt;
&lt;p&gt;That single shift in perspective made the database design much easier to reason about.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-bigger-lesson-from-the-data-model&quot;&gt;The Bigger Lesson From the Data Model&lt;/h2&gt;
&lt;p&gt;At the beginning, the database felt like infrastructure.&lt;/p&gt;
&lt;p&gt;Something beneath the actual application.&lt;/p&gt;
&lt;p&gt;But as the system became more complex, I realized that the data model affects almost every major part of the application:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Performance&lt;/li&gt;
&lt;li&gt;Cost&lt;/li&gt;
&lt;li&gt;Security&lt;/li&gt;
&lt;li&gt;Offline synchronization&lt;/li&gt;
&lt;li&gt;Backend architecture&lt;/li&gt;
&lt;li&gt;Query complexity&lt;/li&gt;
&lt;li&gt;Scalability&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A poor data model doesn't necessarily cause an obvious failure on day one.&lt;/p&gt;
&lt;p&gt;Sometimes the problem is much quieter.&lt;/p&gt;
&lt;p&gt;It simply makes every new feature a little harder to build.&lt;/p&gt;
&lt;p&gt;And that can become a much bigger problem over time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-i-would-change-today&quot;&gt;What I Would Change Today&lt;/h2&gt;
&lt;p&gt;If I were starting this system again today, I would spend considerably more time thinking about the data model before implementing features.&lt;/p&gt;
&lt;p&gt;I'd define:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Entities
   ↓
Relationships
   ↓
Access Patterns
   ↓
Read / Write Frequency
   ↓
Caching Strategy
   ↓
Security Boundaries
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;before writing the first database query.&lt;/p&gt;
&lt;p&gt;I learned this lesson after the system was already functioning.&lt;/p&gt;
&lt;p&gt;Building first showed me what the application genuinely needed.&lt;/p&gt;
&lt;p&gt;But if I rebuilt it from scratch, I'd bring those lessons into the design phase much earlier.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts-3&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;The original requirement sounded simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;I just need somewhere to store attendance.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Over time, that requirement evolved into something much larger:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;I need a data architecture that can support authentication, dynamic QR validation, device verification, offline synchronization, and efficient reads and writes.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Those are two completely different problems.&lt;/p&gt;
&lt;p&gt;And probably one of the biggest lessons this project gave me is that &lt;strong&gt;database design isn't simply about deciding where data should be stored.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It's about defining how the entire application communicates with that data.&lt;/p&gt;
&lt;p&gt;The database isn't simply sitting underneath the architecture.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It is part of the architecture.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-s-next-&quot;&gt;What's Next?&lt;/h2&gt;
&lt;p&gt;The database was only one part of the overall system.&lt;/p&gt;
&lt;p&gt;The next challenge was infrastructure.&lt;/p&gt;
&lt;p&gt;My backend was running on a free-tier service, and that introduced another constraint that became difficult to ignore:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;cold starts.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Which raised another question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;How do you make a slow backend feel fast from the user's perspective?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's the problem I'll explore next.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-project&quot;&gt;🔗 Project&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/siddarthpatelkama/UBA-veltech-attendance-system&quot;&gt;https://github.com/siddarthpatelkama/UBA-veltech-attendance-system&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-your-turn&quot;&gt;💬 Your turn&lt;/h4&gt;
&lt;p&gt;If you've built a Firestore application before, what's one database decision you made early that later saved you from dealing with a much bigger problem?&lt;/p&gt;
&lt;p&gt;I'd love to hear about it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26120/how-i-structured-my-firestore-data-model-for-an-anti-proxy-attendance-system</guid>
<pubDate>Thu, 03 Sep 2026 04:12:28 +0000</pubDate>
</item>
<item>
<title>Layered Authorization: Network to Database</title>
<link>https://coderlegion.com/26028/layered-authorization-network-to-database</link>
<description>&lt;p&gt;This blog is a part of the Zero-Trust Security on Istio Series, if you haven't read the previous article, please do, it will provide better context.&lt;/p&gt;
&lt;p&gt;The previous post covered how Auth Service signs the mesh identity token that carries a user through the mesh. Once a request arrives with a verified identity, the next question is what that identity is allowed to do.&lt;/p&gt;
&lt;p&gt;Authentication tells you who someone is. Authorization tells you what they're allowed to do. In this architecture, authorization runs at four granularities: network, request, resource, and field. Each operates at a different enforcement point with different information available, and each catches failures the others can't. A separate layer, PostgreSQL row-level security, sits at the database and backstops all of them when application code has a bug.&lt;/p&gt;
&lt;h3 id=&quot;why-four-granularities&quot;&gt;Why Four Granularities&lt;/h3&gt;
&lt;p&gt;A single policy engine can't efficiently handle every authorization decision. Some decisions require only the service identity (which pod is calling). Others require the user's role. Others require knowing the specific resource being accessed and the relationship between the user and that resource. And field visibility only makes sense at query time, when you know the actual data attributes.&lt;/p&gt;
&lt;p&gt;Different information is available at different enforcement points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;At the sidecar: mTLS identity and JWT claims. No application context. This is where network-level authorization runs (Istio AuthorizationPolicy).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;At the gateway: user role and route. No specific resource. This is where request-level authorization runs (ExtAuthz).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;At the application: full request context, resource attributes, user-resource relationships. This is where Cerbos makes resource-level and field-level decisions.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;At the database: transaction-level identity. No application logic. This is where row-level security runs. Strictly it's a data-access control rather than authorization, and it's the final enforcement point that backstops the others.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Trying to push all decisions to one point either means that point needs all context (which defeats the separation) or that some decisions can't be made at all.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/6a0c37b1fd930acd9e15653e/0a8d819d-a43b-475e-8a20-7239658b2118.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;network-sidecar-istio-authorizationpolicy&quot;&gt;Network (Sidecar): Istio AuthorizationPolicy&lt;/h3&gt;
&lt;p&gt;The first question: can this service even talk to that service?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-ms1-to-ms2
  namespace: zt-apps
spec:
  selector:
    matchLabels:
      app: ms2-employee-details
  action: ALLOW
  rules:
    - from:
        - source:
            principals:
              - cluster.local/ns/zt-apps/sa/ms1-profile-aggregator-sa
      to:
        - operation:
            ports: [&quot;8000&quot;]
      when:
        - key: request.auth.claims[aud]
          values: [&quot;ms2-employee-details&quot;]
        - key: request.auth.claims[act][sub]
          values: [&quot;ms1-profile-aggregator&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This policy says: only Profile Aggregator's service account (&lt;code&gt;ms1-profile-aggregator-sa&lt;/code&gt;) can reach Employee Records, and only with a token that has the correct audience and delegation claims. Everything else is denied.&lt;/p&gt;
&lt;p&gt;Important nuance: Istio's default behavior when &lt;em&gt;no&lt;/em&gt; AuthorizationPolicy targets a workload is to allow all traffic. Default-deny only kicks in once at least one ALLOW policy exists for that workload. This means every service that should be protected needs an explicit policy. A newly deployed service without a matching policy is open until you write one. In this architecture, every workload in &lt;code&gt;zt-apps&lt;/code&gt; has at least one scoped ALLOW policy, which triggers deny-by-default for unmatched requests.&lt;/p&gt;
&lt;p&gt;What this catches: lateral movement. If Holiday Calendar is compromised, the attacker cannot reach Employee Records. The connection is refused at the sidecar level before any application code processes the request. The attacker would need both the correct service account identity (the mTLS certificate) and a validly-signed token with the right claims.&lt;/p&gt;
&lt;p&gt;The full boundary map:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Source &lt;/th&gt;&lt;th&gt;Can reach &lt;/th&gt;&lt;th&gt;Cannot reach&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Ingress Gateway &lt;/td&gt;&lt;td&gt;Profile Aggregator, Holiday Calendar, Office Directory, Auth Service &lt;/td&gt;&lt;td&gt;Employee Records, Device Inventory, Vault, Postgres&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Profile Aggregator &lt;/td&gt;&lt;td&gt;Employee Records, Device Inventory &lt;/td&gt;&lt;td&gt;Cerbos, Vault, Keycloak, Postgres&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Employee Records, Device Inventory &lt;/td&gt;&lt;td&gt;Cerbos, Postgres (own schema only) &lt;/td&gt;&lt;td&gt;Profile Aggregator, Holiday Calendar, Office Directory, Vault&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holiday Calendar, Office Directory &lt;/td&gt;&lt;td&gt;Cerbos, Postgres (own schema only) &lt;/td&gt;&lt;td&gt;Profile Aggregator, Employee Records, Device Inventory, Vault&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Auth Service &lt;/td&gt;&lt;td&gt;Vault, Postgres &lt;/td&gt;&lt;td&gt;Employee Records, Device Inventory, Holiday Calendar, Office Directory&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every cell in this table is an explicit decision. Unmatched traffic is denied because each workload has at least one ALLOW policy targeting it.&lt;/p&gt;
&lt;h3 id=&quot;request-gateway-extauthz&quot;&gt;Request (Gateway): ExtAuthz&lt;/h3&gt;
&lt;p&gt;The second question: is this user, with this role, allowed to call this route?&lt;/p&gt;
&lt;p&gt;Auth Service (&lt;code&gt;auth-service&lt;/code&gt;) acts as an external authorization check at the gateway. Before minting a mesh token, it checks a route-role policy map:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Route &lt;/th&gt;&lt;th&gt;Method &lt;/th&gt;&lt;th&gt;Allowed Roles&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/api/profile/*&lt;/code&gt; &lt;/td&gt;&lt;td&gt;any &lt;/td&gt;&lt;td&gt;employee, manager, hr_admin, it_admin&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/api/holidays&lt;/code&gt; &lt;/td&gt;&lt;td&gt;GET &lt;/td&gt;&lt;td&gt;employee, manager, hr_admin, it_admin, public_data_admin, security_auditor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/api/holidays&lt;/code&gt; &lt;/td&gt;&lt;td&gt;POST/PUT/DELETE &lt;/td&gt;&lt;td&gt;hr_admin, public_data_admin&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/api/offices/*&lt;/code&gt; &lt;/td&gt;&lt;td&gt;any &lt;/td&gt;&lt;td&gt;&lt;em&gt;(bypasses ExtAuthz entirely; see below)&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The system has six roles total (&lt;code&gt;employee&lt;/code&gt;, &lt;code&gt;manager&lt;/code&gt;, &lt;code&gt;hr_admin&lt;/code&gt;, &lt;code&gt;it_admin&lt;/code&gt;, &lt;code&gt;public_data_admin&lt;/code&gt;, &lt;code&gt;security_auditor&lt;/code&gt;). The core access model for the profile/HR flow is built around the first four. &lt;code&gt;public_data_admin&lt;/code&gt; manages office and holiday data. &lt;code&gt;security_auditor&lt;/code&gt; has read access to non-sensitive endpoints.&lt;/p&gt;
&lt;p&gt;Office routes are the exception: the gateway's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; exempts &lt;code&gt;/api/offices&lt;/code&gt; and &lt;code&gt;/api/offices/*&lt;/code&gt; from ExtAuthz entirely. Reads are public, and write authorization (&lt;code&gt;public_data_admin&lt;/code&gt; only) is enforced downstream by Cerbos at Office Directory, not at the gateway. Everything else routes through the map above.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;public_data_admin&lt;/code&gt; trying to access the profile endpoint gets a 403 at the gateway. They never hit Profile Aggregator. The token is never minted. Downstream services never see the request.&lt;/p&gt;
&lt;p&gt;This is coarse authorization. It knows the user's role and the route they're hitting, but it knows nothing about which specific resource they want to access. A &lt;code&gt;manager&lt;/code&gt; can access the profile endpoint, but that doesn't mean they should see every employee's salary; they should only see pay data for their own direct reports. That's a different layer's job.&lt;/p&gt;
&lt;p&gt;The key decision here is what gets denied early (saving downstream resources and reducing attack surface) versus what gets deferred to finer-grained layers that have more context.&lt;/p&gt;
&lt;h3 id=&quot;resource-and-field-application-cerbos&quot;&gt;Resource and Field (Application): Cerbos&lt;/h3&gt;
&lt;p&gt;The third and fourth granularities: can this user access &lt;em&gt;this specific resource&lt;/em&gt;? And which fields should they see?&lt;/p&gt;
&lt;p&gt;Cerbos evaluates context-aware policies. It receives the principal (user ID, roles, attributes), the resource (type, ID, owner, attributes), and the action. It returns an allow/deny decision and a list of visible fields.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# cerbos/policies/employee_profile.yaml (abbreviated: view/list and hr_admin rules omitted)
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: default
  resource: employee_profile
  rules:
    - actions: [&quot;view_sensitive&quot;, &quot;update&quot;]
      effect: EFFECT_ALLOW
      roles: [&quot;employee&quot;]
      condition:
        match:
          expr: request.principal.id == request.resource.attr.id
      output:
        expr: |-
          {&quot;visible_fields&quot;: [&quot;name&quot;, &quot;title&quot;, &quot;department&quot;, &quot;salary_band&quot;, &quot;base_salary&quot;, &quot;ssn&quot;]}

    - actions: [&quot;view_sensitive&quot;]
      effect: EFFECT_ALLOW
      roles: [&quot;manager&quot;]
      condition:
        match:
          expr: request.principal.id == request.resource.attr.manager_id
      output:
        expr: |-
          {&quot;visible_fields&quot;: [&quot;name&quot;, &quot;title&quot;, &quot;department&quot;, &quot;salary_band&quot;]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What this means concretely: Alice (employee) views her own profile and sees everything including salary and SSN. Bob (manager) views Alice's profile and sees the salary band but not the exact salary or SSN. Carol (another employee) views Alice's profile and sees only name, title, and department.&lt;/p&gt;
&lt;p&gt;Same endpoint. Same authentication. Same route-level authorization. Different data, determined by the relationship between the user and the resource.&lt;/p&gt;
&lt;p&gt;What Cerbos catches that the previous layers cannot: role alone isn't sufficient. A manager should see sensitive data for their direct reports, not for every employee in the company. This requires knowing who the resource belongs to and what the manager's relationship is to that person. The gateway doesn't have that information. Only the application, at query time, knows these relationships.&lt;/p&gt;
&lt;h4 id=&quot;field-masking&quot;&gt;Field Masking&lt;/h4&gt;
&lt;p&gt;Cerbos doesn't filter data itself. It returns a list of visible fields, and the application code uses that list to shape the response. This is a contract between policy and application:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;result = cerbos.check_resource(
    principal={&quot;id&quot;: user_id, &quot;roles&quot;: [role]},
    resource={&quot;kind&quot;: &quot;employee_profile&quot;, &quot;id&quot;: employee_id, &quot;attr&quot;: {&quot;manager_id&quot;: ...}},
    actions=[&quot;view_sensitive&quot;]
)
visible = result.outputs[&quot;visible_fields&quot;]
# Application filters the response to only include these fields
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application must respect this output. If it doesn't, row-level security on the sensitive tables (PII and financials) still limits which rows the database returns, though the directory table is open and relies on Cerbos for field masking. Field-level masking specifically depends on correct application implementation. This is one place where the &quot;security as infrastructure&quot; model has a boundary. The mesh can't mask JSON response fields for you.&lt;/p&gt;
&lt;h3 id=&quot;database-backstop-postgresql-row-level-security&quot;&gt;Database (Backstop): PostgreSQL Row-Level Security&lt;/h3&gt;
&lt;p&gt;The final question: even if everything above passes, should the database return this row?&lt;/p&gt;
&lt;p&gt;Row-level security here is deliberately split. The employee directory table (&lt;code&gt;hr.employees&lt;/code&gt;, holding name, title, department, manager) is readable by any authenticated principal, because the directory itself isn't sensitive and Cerbos handles field masking on top of it. The sensitive tables (&lt;code&gt;hr.employee_pii&lt;/code&gt;, &lt;code&gt;hr.employee_financials&lt;/code&gt;) carry the strict row-level policy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE POLICY employee_financials_visibility ON hr.employee_financials
  USING (
    current_setting('app.current_roles', true) LIKE '%hr_admin%'
    OR employee_id::text = current_setting('app.current_user_id', true)
    OR EXISTS (
      SELECT 1 FROM hr.employees e
      WHERE e.id = employee_id
        AND e.manager_id::text = current_setting('app.current_user_id', true)
    )
  );

ALTER TABLE hr.employee_financials ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.employee_financials FORCE ROW LEVEL SECURITY;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This policy says: you can see a financial record if you're an &lt;code&gt;hr_admin&lt;/code&gt;, if it's your own, or if you manage that employee. Nothing else. A query with no WHERE clause still returns only the rows this user is allowed to see. The PII table (&lt;code&gt;hr.employee_pii&lt;/code&gt;) uses the same shape, and &lt;code&gt;it.hardware_assets&lt;/code&gt; uses self-or-&lt;code&gt;it_admin&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;RLS uses &lt;code&gt;current_setting('app.current_user_id')&lt;/code&gt; and &lt;code&gt;current_setting('app.current_roles')&lt;/code&gt;, which are set by the application at the start of each transaction from the sidecar-projected headers. The database has no knowledge of JWTs, sidecars, or mesh tokens. It just enforces visibility based on the transaction context it's given.&lt;/p&gt;
&lt;h4 id=&quot;why-cerbos-and-rls&quot;&gt;Why Cerbos and RLS&lt;/h4&gt;
&lt;p&gt;The two overlap, but they catch different failures:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cerbos fails (policy bug, service misconfiguration, Cerbos unreachable):&lt;/strong&gt; The services fail closed. If Cerbos is unreachable, the application returns 403 (the client catches all Cerbos errors and denies by default). If a policy bug returns an incorrect allow decision, RLS on the sensitive tables still drops the PII and financial rows the user can't see. The response might leak directory fields that should have been masked, but the sensitive records stay protected.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Application fails (developer forgets to call Cerbos, broken filter logic, new endpoint without an auth check):&lt;/strong&gt; RLS still enforces on the sensitive tables. &lt;code&gt;SELECT * FROM hr.employee_financials&lt;/code&gt; without any Cerbos check still returns only rows matching the RLS policy. The mistake doesn't become a financial-data breach.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RLS fails (misconfigured policy, wrong user ID set on transaction):&lt;/strong&gt; The query order is: the service loads the employee record first (to obtain attributes like &lt;code&gt;manager_id&lt;/code&gt; for the Cerbos policy), then calls Cerbos, then queries the sensitive tables only after Cerbos approves. That initial directory read is intentionally open, so Cerbos is what gates the sensitive query. A broken sensitive-table RLS policy widens which rows come back, but Cerbos already gated access to those tables. A broken Cerbos policy widens what's returned, but RLS still limits sensitive-row visibility.&lt;/p&gt;
&lt;p&gt;Neither Cerbos nor RLS alone is sufficient. Together, an application developer's mistake stops short of full exposure of PII or financial data.&lt;/p&gt;
&lt;h4 id=&quot;schema-isolation&quot;&gt;Schema Isolation&lt;/h4&gt;
&lt;p&gt;RLS is complemented by PostgreSQL GRANT-level isolation. Each service has a dedicated database role:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ms2_hr_role&lt;/code&gt; can only access &lt;code&gt;hr.*&lt;/code&gt; tables (employees, PII, financials)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ms3_it_role&lt;/code&gt; can only access &lt;code&gt;it.hardware_assets&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ms4_public_readwrite_role&lt;/code&gt; can only access &lt;code&gt;public_data.*&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even without RLS, Device Inventory physically cannot query &lt;code&gt;hr.employee_financials&lt;/code&gt;. The GRANT isn't there. This is a separate enforcement layer from RLS. If Device Inventory is compromised and the attacker somehow bypasses Istio AuthorizationPolicy (they'd need the mTLS cert and a valid token), the database still won't serve them HR data because the connection role lacks the privilege.&lt;/p&gt;
&lt;h3 id=&quot;how-the-granularities-compose-a-concrete-example&quot;&gt;How the Granularities Compose: A Concrete Example&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/6a0c37b1fd930acd9e15653e/92b02c68-2529-481c-9afa-4d3c1004f68d.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Alice (employee) requests her own profile with &lt;code&gt;GET /api/profile/{alice_id}&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Network:&lt;/strong&gt; Gateway can reach Profile Aggregator. Profile Aggregator can reach Employee Records. ✓&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Request:&lt;/strong&gt; Alice has the &lt;code&gt;employee&lt;/code&gt; role, &lt;code&gt;/api/profile/*&lt;/code&gt; allows employees. Token minted. ✓&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource and field (Cerbos):&lt;/strong&gt; Alice is requesting her own record (&lt;code&gt;principal.id == resource.attr.id&lt;/code&gt;). Allowed, with visible fields that include salary and SSN.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Database (RLS):&lt;/strong&gt; When the app reads her financials and PII, &lt;code&gt;app.current_user_id&lt;/code&gt; matches the &lt;code&gt;employee_id&lt;/code&gt; on those rows, so they're returned.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Bob (employee) tries to see Alice's financial data. His request also comes in as &lt;code&gt;GET /api/profile/{alice_id}&lt;/code&gt;, and Profile Aggregator fans out toward Alice's sensitive records:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Network:&lt;/strong&gt; Same path. ✓&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Request:&lt;/strong&gt; Bob has the &lt;code&gt;employee&lt;/code&gt; role, &lt;code&gt;/api/profile/*&lt;/code&gt; allows employees. Token minted. ✓&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource and field (Cerbos):&lt;/strong&gt; When the app tries to read Alice's financials, it checks the &lt;code&gt;view_sensitive&lt;/code&gt; action. Bob is not Alice and not Alice's manager. Denied. The &lt;code&gt;/financials&lt;/code&gt; and &lt;code&gt;/pii&lt;/code&gt; endpoints on Employee Records are all-or-nothing, so the response is a 403 with no partial data.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Database (RLS), if Cerbos had a bug and allowed it:&lt;/strong&gt; The RLS policy on &lt;code&gt;hr.employee_financials&lt;/code&gt; requires &lt;code&gt;employee_id == current_user_id&lt;/code&gt; or that the caller manages the employee. Bob is neither. Zero rows returned. The bug at the resource layer is caught at the database.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Bob viewing Alice's basic profile is a different action on the directory: the &lt;code&gt;view&lt;/code&gt; action is allowed for any employee, and Cerbos returns &lt;code&gt;visible_fields: [name, title, department]&lt;/code&gt;. Same request path, separate actions, separate policies, and the directory row is readable while the sensitive tables are not.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What's Next&lt;/h3&gt;
&lt;p&gt;The final post covers the patterns and anti-patterns across all layers: what makes this architecture compose well, what operational pain points exist, and what you'd do differently at scale.&lt;/p&gt;
&lt;h3 id=&quot;code&quot;&gt;Code&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&quot;&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26028/layered-authorization-network-to-database</guid>
<pubDate>Wed, 02 Sep 2026 21:53:08 +0000</pubDate>
</item>
<item>
<title>The Most Important Feature We’re Building Into Our AI Agent Is the Ability to Say “I Don’t Know</title>
<link>https://coderlegion.com/26043/the-most-important-feature-were-building-into-our-ai-agent-is-the-ability-to-say-i-dont-know</link>
<description>&lt;h2 id=&quot;the-most-important-feature-we-re-building-into-our-ai-agent-is-the-ability-to-say-i-don-t-know-&quot;&gt;The Most Important Feature We’re Building Into Our AI Agent Is the Ability to Say “I Don’t Know”&lt;/h2&gt;
&lt;p&gt;AI agents are usually demonstrated by showing what they can do.&lt;/p&gt;
&lt;p&gt;Write code.&lt;/p&gt;
&lt;p&gt;Search the web.&lt;/p&gt;
&lt;p&gt;Analyze documents.&lt;/p&gt;
&lt;p&gt;Create plans.&lt;/p&gt;
&lt;p&gt;Call APIs.&lt;/p&gt;
&lt;p&gt;Automate workflows.&lt;/p&gt;
&lt;p&gt;But while building &lt;strong&gt;Zorgax&lt;/strong&gt;, the AI coordination layer inside MyZubster, I’ve started thinking that one of the most important capabilities of an agent might be the opposite:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;knowing when it does not have enough evidence to act.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-dangerous-part-isn-t-hallucination-alone&quot;&gt;The dangerous part isn't hallucination alone&lt;/h3&gt;
&lt;p&gt;Developers already know about hallucinations.&lt;/p&gt;
&lt;p&gt;A model can generate an answer that sounds perfectly reasonable and is completely wrong.&lt;/p&gt;
&lt;p&gt;But once an AI system becomes an agent, the problem changes.&lt;/p&gt;
&lt;p&gt;A hallucinated paragraph is annoying.&lt;/p&gt;
&lt;p&gt;A hallucinated decision connected to an API can become an action.&lt;/p&gt;
&lt;p&gt;Consider this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;LLM
 ↓
Confident answer
 ↓
API call
 ↓
Database write
 ↓
External action
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now uncertainty isn't just a language problem.&lt;/p&gt;
&lt;p&gt;It's an infrastructure problem.&lt;/p&gt;
&lt;h3 id=&quot;we-started-adding-another-possible-output&quot;&gt;We started adding another possible output&lt;/h3&gt;
&lt;p&gt;In some Zorgax workflows, we're experimenting with explicit uncertainty states.&lt;/p&gt;
&lt;p&gt;Instead of forcing the system to always produce:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;YES
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;NO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;we allow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MORE EVIDENCE REQUIRED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And sometimes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;TIE / HUMAN CHOICE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This sounds almost trivial.&lt;/p&gt;
&lt;p&gt;It isn't.&lt;/p&gt;
&lt;p&gt;A surprising amount of automation architecture assumes that every process eventually produces a machine-decidable answer.&lt;/p&gt;
&lt;p&gt;Reality doesn't work that way.&lt;/p&gt;
&lt;p&gt;Sometimes the available evidence genuinely isn't enough.&lt;/p&gt;
&lt;h3 id=&quot;confidence-is-not-evidence&quot;&gt;Confidence is not evidence&lt;/h3&gt;
&lt;p&gt;Imagine asking an AI agent:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Is this product idea validated?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A language model can easily produce ten paragraphs explaining why the idea looks promising.&lt;/p&gt;
&lt;p&gt;But what evidence exists?&lt;/p&gt;
&lt;p&gt;How many people were interviewed?&lt;/p&gt;
&lt;p&gt;What problems did they report?&lt;/p&gt;
&lt;p&gt;Were the answers independent?&lt;/p&gt;
&lt;p&gt;Did anyone actually test the product?&lt;/p&gt;
&lt;p&gt;Did they simply say “sounds cool”?&lt;/p&gt;
&lt;p&gt;Would they use it?&lt;/p&gt;
&lt;p&gt;Would they pay?&lt;/p&gt;
&lt;p&gt;These are completely different claims.&lt;/p&gt;
&lt;p&gt;So our preferred architecture is becoming:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;QUESTION
   ↓
AVAILABLE EVIDENCE
   ↓
QUALITY CHECK
   ↓
ANALYSIS
   ↓
┌─────────────────────────────┐
│ Sufficient evidence?        │
└─────────────────────────────┘
        ↓              ↓
       YES             NO
        ↓              ↓
Recommendation    MORE EVIDENCE
                      REQUIRED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The AI isn't rewarded for always having an answer.&lt;/p&gt;
&lt;p&gt;Sometimes the correct output is uncertainty.&lt;/p&gt;
&lt;h3 id=&quot;this-changes-how-you-design-agents&quot;&gt;This changes how you design agents&lt;/h3&gt;
&lt;p&gt;Once you accept that principle, several things change.&lt;/p&gt;
&lt;p&gt;The agent needs to distinguish between:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;KNOWN
INFERRED
ASSUMED
UNKNOWN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those states should not be interchangeable.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;KNOWN:
A test returned HTTP 201.

INFERRED:
The endpoint probably created the resource correctly.

UNKNOWN:
Whether the user finds the feature useful.

UNPROVEN:
Whether anyone will pay for it.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An AI system can help with all four categories.&lt;/p&gt;
&lt;p&gt;But it shouldn't silently collapse them into:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;FACT
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-same-problem-appears-in-software-engineering&quot;&gt;The same problem appears in software engineering&lt;/h3&gt;
&lt;p&gt;Suppose Zorgax sees:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;CI: green
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What does that prove?&lt;/p&gt;
&lt;p&gt;It proves the tests passed.&lt;/p&gt;
&lt;p&gt;It does not prove:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;the architecture is correct
the product is secure
users want the feature
production will never fail
the business will succeed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Green CI is evidence for a specific claim.&lt;/p&gt;
&lt;p&gt;Nothing more.&lt;/p&gt;
&lt;p&gt;I think this is an important mental model for AI systems:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every piece of evidence has a scope.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;and-it-becomes-even-more-important-with-iot&quot;&gt;And it becomes even more important with IoT&lt;/h3&gt;
&lt;p&gt;MyZubster is also exploring systems involving sensors and physical-world evidence.&lt;/p&gt;
&lt;p&gt;Now imagine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Sensor
  ↓
Data
  ↓
AI
  ↓
Decision
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A sensor reports something unusual.&lt;/p&gt;
&lt;p&gt;Should the AI immediately act?&lt;/p&gt;
&lt;p&gt;Maybe.&lt;/p&gt;
&lt;p&gt;But first:&lt;/p&gt;
&lt;p&gt;Is the sensor healthy?&lt;/p&gt;
&lt;p&gt;Is the reading within its calibration range?&lt;/p&gt;
&lt;p&gt;Is the timestamp valid?&lt;/p&gt;
&lt;p&gt;Is the measurement recent?&lt;/p&gt;
&lt;p&gt;Do other sensors agree?&lt;/p&gt;
&lt;p&gt;Is the data complete?&lt;/p&gt;
&lt;p&gt;Was there a communication failure?&lt;/p&gt;
&lt;p&gt;A better pipeline might be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;PHYSICAL EVENT
      ↓
SENSOR DATA
      ↓
PROVENANCE
      ↓
QUALITY CHECK
      ↓
ZORGAX
      ↓
CONFIDENCE / EVIDENCE GATE
      ↓
HUMAN DECISION
      ↓
ACTION
      ↓
MEASUREMENT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The AI becomes part of the evidence pipeline.&lt;/p&gt;
&lt;p&gt;Not the source of truth.&lt;/p&gt;
&lt;h3 id=&quot;-i-don-t-know-needs-architecture&quot;&gt;“I don't know” needs architecture&lt;/h3&gt;
&lt;p&gt;There is another problem.&lt;/p&gt;
&lt;p&gt;Simply prompting an LLM:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Say you don't know when uncertain.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;isn't enough.&lt;/p&gt;
&lt;p&gt;The surrounding software needs to understand uncertainty too.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const result = await zorgax.evaluate(evidence);

switch (result.status) {
  case &quot;SUPPORTED&quot;:
    prepareRecommendation(result);
    break;

  case &quot;INSUFFICIENT_EVIDENCE&quot;:
    requestMoreEvidence(result.missing);
    break;

  case &quot;CONFLICTING_EVIDENCE&quot;:
    escalateToHuman(result);
    break;

  default:
    failClosed();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now uncertainty becomes a system state.&lt;/p&gt;
&lt;p&gt;That is much more interesting than treating it as conversational wording.&lt;/p&gt;
&lt;h3 id=&quot;fail-closed-instead-of-inventing-certainty&quot;&gt;Fail closed instead of inventing certainty&lt;/h3&gt;
&lt;p&gt;Security engineering already uses this idea.&lt;/p&gt;
&lt;p&gt;If authorization cannot be verified:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DO NOT GRANT ACCESS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If payment cannot be verified:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DO NOT ACTIVATE ENTITLEMENT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If identity cannot be verified:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DO NOT MARK VERIFIED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why shouldn't AI reasoning use a similar principle?&lt;/p&gt;
&lt;p&gt;If evidence cannot support the claim:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DO NOT INVENT CONFIDENCE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This doesn't mean doing nothing forever.&lt;/p&gt;
&lt;p&gt;It means returning the missing information required to continue.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;INSUFFICIENT_EVIDENCE

Missing:
- additional observations
- independent confirmation
- baseline measurement
- verified identity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now uncertainty becomes actionable.&lt;/p&gt;
&lt;h3 id=&quot;human-in-the-loop-becomes-more-precise&quot;&gt;Human-in-the-loop becomes more precise&lt;/h3&gt;
&lt;p&gt;“Human-in-the-loop” is sometimes used as a vague safety phrase.&lt;/p&gt;
&lt;p&gt;But the interesting question is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When exactly does the human enter the loop?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not every operation needs approval.&lt;/p&gt;
&lt;p&gt;Reading a public document?&lt;/p&gt;
&lt;p&gt;Probably not.&lt;/p&gt;
&lt;p&gt;Calculating a metric?&lt;/p&gt;
&lt;p&gt;Probably not.&lt;/p&gt;
&lt;p&gt;Preparing a draft?&lt;/p&gt;
&lt;p&gt;Probably not.&lt;/p&gt;
&lt;p&gt;Sending money?&lt;/p&gt;
&lt;p&gt;Yes.&lt;/p&gt;
&lt;p&gt;Changing verified identity?&lt;/p&gt;
&lt;p&gt;Yes.&lt;/p&gt;
&lt;p&gt;Publishing a consequential claim?&lt;/p&gt;
&lt;p&gt;Potentially.&lt;/p&gt;
&lt;p&gt;Acting when evidence is contradictory?&lt;/p&gt;
&lt;p&gt;Definitely.&lt;/p&gt;
&lt;p&gt;So instead of:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;AI → HUMAN → EVERYTHING
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;we are exploring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;LOW-RISK / REVERSIBLE
AI can prepare or execute within scope

HIGH-RISK / IRREVERSIBLE
Human approval required

INSUFFICIENT EVIDENCE
Stop and request evidence
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That makes the boundary much easier to reason about.&lt;/p&gt;
&lt;h3 id=&quot;agents-need-epistemic-permissions&quot;&gt;Agents need epistemic permissions&lt;/h3&gt;
&lt;p&gt;We already think about permissions like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;READ
WRITE
DELETE
EXECUTE
ADMIN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Maybe AI systems also need something like epistemic permissions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;OBSERVE
INFER
RECOMMEND
CLAIM
ACT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An agent might be allowed to infer something internally without being allowed to publish it as fact.&lt;/p&gt;
&lt;p&gt;It might be allowed to recommend an action without being allowed to execute it.&lt;/p&gt;
&lt;p&gt;It might be allowed to prepare a database change without being allowed to commit it.&lt;/p&gt;
&lt;p&gt;That separation is becoming increasingly important in how we're designing Zorgax.&lt;/p&gt;
&lt;h3 id=&quot;the-architecture-we-re-moving-toward&quot;&gt;The architecture we're moving toward&lt;/h3&gt;
&lt;p&gt;The larger loop currently looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;KNOWLEDGE
    ↓
EVIDENCE
    ↓
REASONING
    ↓
UNCERTAINTY CHECK
    ↓
RECOMMENDATION
    ↓
HUMAN APPROVAL
    ↓
ACTION
    ↓
MEASUREMENT
    ↓
LEARNING
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The uncertainty check might be one of the most important components in that diagram.&lt;/p&gt;
&lt;p&gt;Because without it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;REASONING
   ↓
CONFIDENCE
   ↓
ACTION
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;can become dangerous very quickly.&lt;/p&gt;
&lt;h3 id=&quot;the-goal-isn-t-an-ai-that-always-answers&quot;&gt;The goal isn't an AI that always answers&lt;/h3&gt;
&lt;p&gt;The AI industry often measures capability by asking:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How many tasks can the model complete?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For agents interacting with real systems, I think another metric will become increasingly important:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How reliably does the system recognize when it should not proceed?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A useful agent should be capable.&lt;/p&gt;
&lt;p&gt;But it should also understand boundaries.&lt;/p&gt;
&lt;p&gt;It should distinguish evidence from inference.&lt;/p&gt;
&lt;p&gt;It should expose uncertainty.&lt;/p&gt;
&lt;p&gt;It should fail safely.&lt;/p&gt;
&lt;p&gt;And sometimes it should produce the least impressive AI answer possible:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;I DON'T KNOW YET.

MORE EVIDENCE REQUIRED.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That might actually be a feature.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I'm building &lt;strong&gt;MyZubster + Zorgax&lt;/strong&gt; in public and experimenting with AI agents, open-source infrastructure, human approval, IoT and evidence-driven automation.&lt;/p&gt;
&lt;p&gt;I'm curious how other developers are handling this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does your agent architecture have an explicit “insufficient evidence” state, or does the model always have to produce an answer?&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26043/the-most-important-feature-were-building-into-our-ai-agent-is-the-ability-to-say-i-dont-know</guid>
<pubDate>Wed, 02 Sep 2026 01:14:41 +0000</pubDate>
</item>
<item>
<title>The Identity Flow: From Gateway to Database</title>
<link>https://coderlegion.com/26025/the-identity-flow-from-gateway-to-database</link>
<description>&lt;p&gt;This blog is a part of the Zero-Trust Security on Istio Series, if you haven't read the previous article, please do, it will provide better context.&lt;/p&gt;
&lt;p&gt;The previous post laid out the five security layers as a static architecture. This post follows a single request through them, down to the question that matters in practice: what does a service actually trust, and why is trusting it safe?&lt;/p&gt;
&lt;p&gt;The system is an HR employee directory, built to be attacked. Five microservices sit behind the mesh, numbered &lt;code&gt;ms1&lt;/code&gt; through &lt;code&gt;ms5&lt;/code&gt;: Profile Aggregator (&lt;code&gt;ms1&lt;/code&gt;) is the gateway-facing orchestrator; Employee Records (&lt;code&gt;ms2&lt;/code&gt;) holds PII and salary data and Device Inventory (&lt;code&gt;ms3&lt;/code&gt;) holds hardware assignments, both internal-only; Holiday Calendar (&lt;code&gt;ms4&lt;/code&gt;) and Office Directory (&lt;code&gt;ms5&lt;/code&gt;) serve holiday and office-location data, most of it public. Auth Service mints a signed identity token, &lt;code&gt;x-mesh-identity&lt;/code&gt;, that carries the user through the mesh, and Vault signs it. This post traces one concrete request: an employee, Alice, requests her own profile with &lt;code&gt;GET /api/profile/{id}&lt;/code&gt;. That request reaches Profile Aggregator, which fans out to Employee Records and Device Inventory. I'll follow the &lt;code&gt;ms1&lt;/code&gt; to &lt;code&gt;ms2&lt;/code&gt; hop, Profile Aggregator calling Employee Records, so every &lt;code&gt;ms2-*&lt;/code&gt; resource below is that running example, not a special case.&lt;/p&gt;
&lt;p&gt;Employee Records reads the user's identity from a plain HTTP header, eg &lt;code&gt;x-ms2-user&lt;/code&gt;. The header is named per service (&lt;code&gt;ms2&lt;/code&gt; reads &lt;code&gt;x-ms2-user&lt;/code&gt;, &lt;code&gt;ms3&lt;/code&gt; reads &lt;code&gt;x-ms3-user&lt;/code&gt;) so one service can never consume another's identity headers. Employee Records does no JWT parsing and makes no callback to an auth service. It reads that header and treats the value as the authenticated user. That should look fragile, because headers are trivially spoofable. Any external client can send &lt;code&gt;x-ms2-user: alice&lt;/code&gt;, and so can any compromised pod inside the cluster.&lt;/p&gt;
&lt;p&gt;It works because of everything that happens before a service reads that header. A single request moves through eight steps from the gateway to the database, each adding one guarantee:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Header stripping&lt;/strong&gt; at the gateway.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authentication&lt;/strong&gt; of the caller's credentials.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Route-level authorization&lt;/strong&gt; against a coarse policy map.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token minting&lt;/strong&gt;, signed via Vault.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token validation and header projection&lt;/strong&gt; at the destination sidecar.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AuthorizationPolicy&lt;/strong&gt;, binding peer identity to token claims.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The service contract&lt;/strong&gt;, the two headers the application actually reads.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Database identity context&lt;/strong&gt; for row-level enforcement.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Six of those steps run in the mesh before the application sees the request. By the time Employee Records reads &lt;code&gt;x-ms2-user&lt;/code&gt;, its value has been stripped of anything the caller sent and re-derived from the signed token the destination sidecar verified. The last two steps are the service's own contract and the database scoping it then sets up.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/6a0c37b1fd930acd9e15653e/36558b98-f8f8-4da1-8ef3-327a1f3eaa47.png&quot; alt=&quot;The identity flow: eight checkpoints from the Istio gateway to the PostgreSQL row-level security query&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-trust-boundary&quot;&gt;The Trust Boundary&lt;/h3&gt;
&lt;p&gt;The Istio Ingress Gateway is where external input is converted into internal identity. Everything before it is untrusted. An external client can send any header it wants, including headers that look exactly like internal identity assertions. The gateway's job is to strip those, authenticate the caller, and produce a cryptographically signed token that downstream services can verify independently.&lt;/p&gt;
&lt;p&gt;After the gateway, a service trusts only the headers its own destination sidecar wrote. The sidecar does the verification itself: it strips any inbound copy of the projected headers (like &lt;code&gt;x-ms2-user&lt;/code&gt;), validates the JWT signature, checks the issuer, and verifies expiry, then writes the claims into fresh headers. The application reads those headers directly and runs no verification of its own, because the sidecar already ran it.&lt;/p&gt;
&lt;p&gt;That trust is narrowly scoped. It covers only the headers the sidecar itself wrote after validating the token. Anything that merely arrived on the request, from an external client or an internal pod, is ignored. So if a compromised internal pod sends a spoofed &lt;code&gt;x-ms2-user&lt;/code&gt; header, the destination sidecar strips it before the JWT filter runs and re-projects the value from the verified token. The spoofed header never reaches the application.&lt;/p&gt;
&lt;p&gt;This model has a clear contract: the gateway is responsible for identity creation, sidecars are responsible for identity verification, and services are responsible for business logic. If any of those three fail in isolation, the other two still limit the blast radius.&lt;/p&gt;
&lt;h3 id=&quot;step-1-header-stripping&quot;&gt;Step 1: Header Stripping&lt;/h3&gt;
&lt;p&gt;Before authentication, before routing, before anything else, a Lua EnvoyFilter removes every internal trust header from the incoming request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: gateway-prestrip
  namespace: istio-system
spec:
  priority: -100
  workloadSelector:
    labels:
      istio: ingressgateway
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: GATEWAY
        listener:
          filterChain:
            filter:
              name: &quot;envoy.filters.network.http_connection_manager&quot;
              subFilter:
                name: &quot;envoy.filters.http.ext_authz&quot;
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.lua.prestrip
          typed_config:
            &quot;@type&quot;: type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
            inlineCode: |
              function envoy_on_request(request_handle)
                local headers_to_remove = {}
                for key, value in pairs(request_handle:headers()) do
                  local lower_key = string.lower(key)
                  if string.match(lower_key, &quot;^x%-platform%-&quot;) or
                     string.match(lower_key, &quot;^x%-role%-&quot;) or
                     lower_key == &quot;x-mesh-identity&quot; or
                     string.match(lower_key, &quot;^x%-ms%d%-user&quot;) or
                     string.match(lower_key, &quot;^x%-ms%d%-role&quot;) then
                    table.insert(headers_to_remove, lower_key)
                  end
                end
                for _, key in ipairs(headers_to_remove) do
                  request_handle:headers():remove(key)
                end
              end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The filter runs at priority &lt;code&gt;-100&lt;/code&gt; with &lt;code&gt;INSERT_BEFORE&lt;/code&gt; the ExtAuthz filter. The order is load-bearing: if ExtAuthz ran first and injected &lt;code&gt;x-mesh-identity&lt;/code&gt; based on a valid session, but a spoofed &lt;code&gt;x-ms2-user&lt;/code&gt; header also survived from the client, the downstream service would read the attacker-controlled value.&lt;/p&gt;
&lt;p&gt;The two-pass approach (collect keys, then remove) is required because mutating headers during iteration is undefined behavior in Envoy's Lua API.&lt;/p&gt;
&lt;p&gt;What gets stripped: &lt;code&gt;x-platform-*&lt;/code&gt;, &lt;code&gt;x-role-*&lt;/code&gt;, &lt;code&gt;x-mesh-identity&lt;/code&gt;, and all &lt;code&gt;x-ms&amp;lt;N&amp;gt;-user&lt;/code&gt; / &lt;code&gt;x-ms&amp;lt;N&amp;gt;-role&lt;/code&gt; headers. Case-insensitive matching ensures no variant slips through.&lt;/p&gt;
&lt;h3 id=&quot;step-2-authentication-extauthz-&quot;&gt;Step 2: Authentication (ExtAuthz)&lt;/h3&gt;
&lt;p&gt;After stripping, the request hits the ExtAuthz filter. This calls Auth Service (&lt;code&gt;auth-service&lt;/code&gt;), which resolves the caller's identity from one of two sources:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bearer token:&lt;/strong&gt; Extracted from the &lt;code&gt;Authorization&lt;/code&gt; header, validated against Keycloak's JWKS endpoint. Roles come from &lt;code&gt;realm_access.roles&lt;/code&gt; in the Keycloak JWT.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Session cookie:&lt;/strong&gt; An opaque &lt;code&gt;zt_session&lt;/code&gt; cookie, looked up in PostgreSQL. The session record holds the user's roles from the original OIDC flow.&lt;/p&gt;
&lt;p&gt;Both paths produce the same output: a subject identifier, username, email, roles, groups, and department. The subject is deterministic across both auth paths. It's computed as &lt;code&gt;uuid5(NAMESPACE_URL, &quot;istio-security://users/&amp;lt;username&amp;gt;&quot;)&lt;/code&gt;, which means the same user always gets the same &lt;code&gt;sub&lt;/code&gt; regardless of whether they authenticated via cookie or bearer token. This is important for RLS (which filters by &lt;code&gt;sub&lt;/code&gt;) and for audit trails.&lt;/p&gt;
&lt;h3 id=&quot;step-3-route-level-authorization&quot;&gt;Step 3: Route-Level Authorization&lt;/h3&gt;
&lt;p&gt;Before minting a token, Auth Service checks a coarse policy map. This is the first authorization gate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Coarse route/role policy map (ext_authz.py)
if path.startswith(&quot;/api/profile/&quot;):
    allowed_roles = {&quot;employee&quot;, &quot;manager&quot;, &quot;hr_admin&quot;, &quot;it_admin&quot;}
    decision[&quot;audience&quot;] = [&quot;ms1-profile-aggregator&quot;, &quot;ms2-employee-details&quot;, &quot;ms3-hardware-assets&quot;]
elif path.startswith(&quot;/api/holidays&quot;) and method == &quot;GET&quot;:
    allowed_roles = {&quot;employee&quot;, &quot;manager&quot;, &quot;hr_admin&quot;, &quot;it_admin&quot;, &quot;public_data_admin&quot;, &quot;security_auditor&quot;}
    decision[&quot;audience&quot;] = [&quot;ms4-holiday-calendar&quot;]
elif path.startswith(&quot;/api/holidays&quot;) and method in (&quot;POST&quot;, &quot;PUT&quot;, &quot;DELETE&quot;, &quot;PATCH&quot;):
    allowed_roles = {&quot;public_data_admin&quot;, &quot;hr_admin&quot;}
    decision[&quot;audience&quot;] = [&quot;ms4-holiday-calendar&quot;]
else:
    decision[&quot;reason&quot;] = &quot;Route not defined in coarse policy map&quot;
    return decision

user_roles = set(decision[&quot;roles&quot;])
if not allowed_roles.intersection(user_roles):
    decision[&quot;reason&quot;] = &quot;Unauthorized role for route&quot;
    return decision
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the user's role doesn't match the route, the request is denied with 401. No token is minted. The &lt;code&gt;audience&lt;/code&gt; field determines which services this token will be valid for. A token for the profile endpoint is valid at Profile Aggregator (&lt;code&gt;ms1-profile-aggregator&lt;/code&gt;), Employee Records (&lt;code&gt;ms2-employee-details&lt;/code&gt;), and Device Inventory (&lt;code&gt;ms3-hardware-assets&lt;/code&gt;). A token for the holidays endpoint is valid only at Holiday Calendar (&lt;code&gt;ms4-holiday-calendar&lt;/code&gt;). This prevents a valid token from one flow being replayed against a different service.&lt;/p&gt;
&lt;p&gt;Any path not in the policy map is denied by default. &lt;code&gt;GET /api/offices&lt;/code&gt; is deliberately absent, because those reads are public: the gateway's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; exempts them from ExtAuthz, so an unauthenticated read reaches Office Directory (&lt;code&gt;ms5-office-locations&lt;/code&gt;) without a token and is treated as &lt;code&gt;anonymous/public&lt;/code&gt;. Writes to &lt;code&gt;/api/offices&lt;/code&gt; do go through the map above and require &lt;code&gt;public_data_admin&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;step-4-token-minting&quot;&gt;Step 4: Token Minting&lt;/h3&gt;
&lt;p&gt;If authentication and route authorization both pass, Auth Service mints a signed mesh token. The JWT is built manually (no library) and signed via Vault Transit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;header = {&quot;alg&quot;: &quot;RS256&quot;, &quot;typ&quot;: &quot;JWT&quot;, &quot;kid&quot;: str(vault_key_version)}

payload = {
    &quot;iss&quot;: &quot;auth-service&quot;,
    &quot;sub&quot;: subject_uuid,
    &quot;preferred_username&quot;: username,
    &quot;roles&quot;: [&quot;employee&quot;, &quot;manager&quot;],
    &quot;roles_csv&quot;: &quot;employee,manager&quot;,
    &quot;aud&quot;: [&quot;ms1-profile-aggregator&quot;, &quot;ms2-employee-details&quot;, &quot;ms3-hardware-assets&quot;],
    &quot;act&quot;: {&quot;sub&quot;: &quot;ms1-profile-aggregator&quot;},
    &quot;exp&quot;: now + 300,  # 5 minutes
    &quot;jti&quot;: str(uuid4()),
    &quot;request_id&quot;: request_id
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The signing input (&lt;code&gt;header_b64.payload_b64&lt;/code&gt;) is sent to Vault Transit's &lt;code&gt;/v1/transit/sign/mesh-identity&lt;/code&gt; endpoint. Vault signs it with the RSA-2048 private key and returns the signature. Auth Service never holds the private key.&lt;/p&gt;
&lt;p&gt;Key details:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;roles_csv&lt;/code&gt; exists because Istio's &lt;code&gt;outputClaimToHeaders&lt;/code&gt; can only project string claims, not arrays.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;act&lt;/code&gt; (the delegation claim from RFC 8693) is set to &lt;code&gt;ms1-profile-aggregator&lt;/code&gt; when Profile Aggregator is in the audience. Auth Service sets this at mint time because it already knows (from the route-policy map) that the profile flow will pass through Profile Aggregator to reach Employee Records and Device Inventory. The &lt;code&gt;act&lt;/code&gt; claim asserts that Profile Aggregator is the authorized intermediary for this request. When Profile Aggregator forwards the token downstream, the receiving AuthorizationPolicy verifies both the mTLS identity (is this actually Profile Aggregator?) and the &lt;code&gt;act&lt;/code&gt; claim (was Profile Aggregator designated as the intermediary?). If a different service somehow obtained this token, the mTLS check would fail.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;kid&lt;/code&gt; is fetched live from Vault on every mint. When keys rotate, new tokens immediately reference the new version.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;TTL is 5 minutes. Short enough to limit replay windows, long enough that a fan-out from Profile Aggregator to Employee Records and Device Inventory completes comfortably.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The JWT is assembled manually rather than via a library like PyJWT. This is because the signing step goes through Vault Transit's API (not a local key), and standard JWT libraries assume they hold the private key. Building the token by hand keeps the signing interface clean: encode header and payload, send bytes to Vault, attach the returned signature.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;step-5-token-validation-and-header-projection&quot;&gt;Step 5: Token Validation and Header Projection&lt;/h3&gt;
&lt;p&gt;The signed token travels as the &lt;code&gt;x-mesh-identity&lt;/code&gt; header. The destination sidecar handles it in two stages before the application sees the request.&lt;/p&gt;
&lt;p&gt;First, a Lua EnvoyFilter strips any inbound &lt;code&gt;x-ms2-user&lt;/code&gt; / &lt;code&gt;x-ms2-role&lt;/code&gt; headers. It runs at &lt;code&gt;priority: 10&lt;/code&gt; with &lt;code&gt;INSERT_BEFORE&lt;/code&gt; the JWT authentication filter, so spoofed values are removed before any claim is projected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: header-projection-ms2
  namespace: zt-apps
spec:
  priority: 10
  workloadSelector:
    labels:
      app: ms2-employee-details
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: &quot;envoy.filters.network.http_connection_manager&quot;
              subFilter:
                name: &quot;envoy.filters.http.jwt_authn&quot;
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.lua.projection
          typed_config:
            &quot;@type&quot;: type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
            inlineCode: |
              function envoy_on_request(request_handle)
                request_handle:headers():remove(&quot;x-ms2-user&quot;)
                request_handle:headers():remove(&quot;x-ms2-role&quot;)
              end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the destination-side counterpart to the gateway pre-strip described in &lt;em&gt;The Trust Boundary&lt;/em&gt;: the same strip-then-project pattern, now enforced at the workload. Any inbound copy of these headers is removed before the JWT filter runs.&lt;/p&gt;
&lt;p&gt;Then the &lt;code&gt;RequestAuthentication&lt;/code&gt; resource validates the token and projects the verified claims:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: mesh-identity-ms2
  namespace: zt-apps
spec:
  selector:
    matchLabels:
      app: ms2-employee-details
  jwtRules:
    - issuer: &quot;auth-service&quot;
      jwksUri: &quot;http://auth-service.zt-apps.svc.cluster.local:8000/auth/jwks&quot;
      forwardOriginalToken: false
      fromHeaders:
        - name: &quot;x-mesh-identity&quot;
      outputClaimToHeaders:
        - header: &quot;x-ms2-user&quot;
          claim: &quot;sub&quot;
        - header: &quot;x-ms2-role&quot;
          claim: &quot;roles_csv&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sidecar fetches public keys from the Auth Service JWKS endpoint, validates the JWT signature, checks &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt;, and then projects &lt;code&gt;sub&lt;/code&gt; into &lt;code&gt;x-ms2-user&lt;/code&gt; and &lt;code&gt;roles_csv&lt;/code&gt; into &lt;code&gt;x-ms2-role&lt;/code&gt;. Because the strip filter already cleared any inbound copies, these headers can only carry claims from a validated token. These are the only headers the application reads.&lt;/p&gt;
&lt;p&gt;One Istio nuance is easy to miss: &lt;code&gt;RequestAuthentication&lt;/code&gt; validates a JWT when one is present. It does not, by itself, mean &quot;a JWT is required.&quot; The requirement comes from the &lt;code&gt;AuthorizationPolicy&lt;/code&gt; in the next step. That policy checks claims that only exist after successful validation, so a missing or invalid token has no matching rule and is denied.&lt;/p&gt;
&lt;p&gt;Profile Aggregator has &lt;code&gt;forwardOriginalToken: true&lt;/code&gt; because it needs to propagate &lt;code&gt;x-mesh-identity&lt;/code&gt; to Employee Records and Device Inventory. All other services have it set to &lt;code&gt;false&lt;/code&gt; since they're terminal and don't make downstream calls that need identity propagation.&lt;/p&gt;
&lt;h3 id=&quot;step-6-authorizationpolicy&quot;&gt;Step 6: AuthorizationPolicy&lt;/h3&gt;
&lt;p&gt;After token validation, Istio's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; enforces token presence, token scope, and peer identity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-ms1-to-ms2
  namespace: zt-apps
spec:
  selector:
    matchLabels:
      app: ms2-employee-details
  action: ALLOW
  rules:
    - from:
        - source:
            principals:
              - cluster.local/ns/zt-apps/sa/ms1-profile-aggregator-sa
      to:
        - operation:
            ports: [&quot;8000&quot;]
      when:
        - key: request.auth.claims[aud]
          values: [&quot;ms2-employee-details&quot;]
        - key: request.auth.claims[act][sub]
          values: [&quot;ms1-profile-aggregator&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things must be true simultaneously: the request must come from &lt;code&gt;ms1-profile-aggregator-sa&lt;/code&gt; over mTLS, the token must be valid for &lt;code&gt;ms2-employee-details&lt;/code&gt;, and the token must name &lt;code&gt;ms1-profile-aggregator&lt;/code&gt; as the authorized intermediary. The peer identity comes from the client certificate. The &lt;code&gt;aud&lt;/code&gt; and &lt;code&gt;act&lt;/code&gt; values come from the signed JWT. These are independent cryptographic assertions. Compromising one doesn't give you the other.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;aud&lt;/code&gt; claim is a list because a single profile request legitimately reaches more than one service. Istio's claim matching checks whether the configured value is present in that claim, so a token minted for Profile Aggregator, Employee Records, and Device Inventory can match &lt;code&gt;ms2-employee-details&lt;/code&gt; here. A token minted only for Holiday Calendar would not match.&lt;/p&gt;
&lt;p&gt;This is confused-deputy prevention: stopping a privileged intermediary from being tricked into using its authority on behalf of an attacker. If Profile Aggregator's service account is somehow used by a different process, the process would still need a validly-signed token that authorizes Profile Aggregator to call Employee Records for this user flow. If an attacker replays a legitimate token from a different pod, the mTLS identity check fails. Both bindings must match.&lt;/p&gt;
&lt;p&gt;The deny behavior depends on having an &lt;code&gt;AuthorizationPolicy&lt;/code&gt; that targets the workload. Istio allows traffic by default when no policy applies. In this architecture, every protected workload has at least one scoped &lt;code&gt;ALLOW&lt;/code&gt; policy, so unmatched traffic is denied. A request with no token, a bad signature, the wrong audience, or the wrong caller identity has no matching rule and never reaches the application.&lt;/p&gt;
&lt;p&gt;Fine-grained authorization (can this user see &lt;em&gt;this specific record&lt;/em&gt;, and which fields?) also runs at this point, via Cerbos. That's the resource and field-level layer, covered in a later post. This post stays on the identity flow.&lt;/p&gt;
&lt;h3 id=&quot;step-7-the-service-contract&quot;&gt;Step 7: The Service Contract&lt;/h3&gt;
&lt;p&gt;After all of this, what does the service actually see? Two headers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;async def get_ms2_headers(
    x_ms2_user: str | None = Header(None),
    x_ms2_role: str | None = Header(None),
    x_request_id: str | None = Header(None),
):
    if not x_ms2_user or not x_ms2_role:
        raise HTTPException(status_code=401, detail=&quot;Missing required legacy headers&quot;)
    return {&quot;user&quot;: x_ms2_user, &quot;role&quot;: x_ms2_role, &quot;request_id&quot;: x_request_id}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The service reads &lt;code&gt;x-ms2-user&lt;/code&gt; and &lt;code&gt;x-ms2-role&lt;/code&gt;. It returns 401 if they're missing. That's the entire identity contract. The 401 message calls them &quot;legacy&quot; headers, but the name only refers to this plain-header interface: the service reads simple headers as if it had no awareness of the mesh, and the mesh does the real verification upstream. The service does no JWT parsing, OIDC configuration, or Vault calls of its own. It trusts that if these headers are present, the sidecar has already done the verification.&lt;/p&gt;
&lt;p&gt;Profile Aggregator forwards &lt;code&gt;x-mesh-identity&lt;/code&gt; and &lt;code&gt;x-request-id&lt;/code&gt; to downstream services. It explicitly does not forward &lt;code&gt;x-ms2-user&lt;/code&gt; or &lt;code&gt;x-ms2-role&lt;/code&gt; since the receiving sidecar projects those independently from the JWT. Profile Aggregator cannot influence what identity Employee Records sees.&lt;/p&gt;
&lt;h3 id=&quot;step-8-database-identity-context&quot;&gt;Step 8: Database Identity Context&lt;/h3&gt;
&lt;p&gt;The final step. Before executing any query, the service sets the transaction context:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;async def set_rls_context(session: AsyncSession, user_id: str, roles: str, request_id: str):
    await session.execute(
        text(&quot;SELECT set_config('app.current_user_id', :user_id, true)&quot;),
        {&quot;user_id&quot;: user_id},
    )
    await session.execute(
        text(&quot;SELECT set_config('app.current_roles', :roles, true)&quot;),
        {&quot;roles&quot;: roles},
    )
    if request_id:
        await session.execute(
            text(&quot;SELECT set_config('app.request_id', :request_id, true)&quot;),
            {&quot;request_id&quot;: request_id},
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostgreSQL RLS policies read &lt;code&gt;app.current_user_id&lt;/code&gt; and &lt;code&gt;app.current_roles&lt;/code&gt; to filter rows; &lt;code&gt;app.request_id&lt;/code&gt; is set for audit correlation. The &lt;code&gt;true&lt;/code&gt; third argument makes each setting transaction-local, so it doesn't leak across pooled connections.&lt;/p&gt;
&lt;p&gt;At this point, the database sees the same user identity that the sidecar projected from the verified token. In normal operation, the service is a pass-through for identity. It reads the sidecar-projected header and sets it on the transaction.&lt;/p&gt;
&lt;p&gt;A fully compromised service process could call &lt;code&gt;set_config&lt;/code&gt; with an arbitrary value. RLS doesn't protect against a malicious process controlling its own DB session. But the layers above constrain this: the compromised service only has its own scoped DB role (Employee Records can only access &lt;code&gt;hr.*&lt;/code&gt; tables, Device Inventory only &lt;code&gt;it.*&lt;/code&gt;), and AuthorizationPolicy prevents it from reaching other services' databases. The blast radius of a compromised Employee Records service is limited to the HR data it already has access to, scoped by RLS to the identities it actually receives requests for.&lt;/p&gt;
&lt;h3 id=&quot;failure-behavior&quot;&gt;Failure Behavior&lt;/h3&gt;
&lt;p&gt;The secure path matters, but so does the failure path. A missing or invalid mesh token fails at &lt;code&gt;AuthorizationPolicy&lt;/code&gt; because the required claims are absent. If Auth Service is down, ExtAuthz cannot approve the request and no mesh token is minted. If Vault is unreachable, Auth Service cannot sign a token. If the sidecar cannot refresh JWKS, it continues using its cached keys until the cache expires; after that, validation fails closed for tokens it cannot verify.&lt;/p&gt;
&lt;p&gt;The one operational caveat is key rotation. Istio sidecars cache JWKS, so there is a short window where a sidecar may still verify tokens with a cached old public key. The next post covers that rotation window and why the token TTL is only 5 minutes.&lt;/p&gt;
&lt;h3 id=&quot;the-complete-chain&quot;&gt;The Complete Chain&lt;/h3&gt;
&lt;p&gt;Gateway strips external headers -&amp;gt; Auth Service validates credentials -&amp;gt; Auth Service checks route-level policy -&amp;gt; Auth Service mints a signed token -&amp;gt; sidecar validates the token and projects claims -&amp;gt; AuthorizationPolicy checks peer identity and token claims -&amp;gt; service reads projected headers -&amp;gt; database enforces row-level visibility.&lt;/p&gt;
&lt;p&gt;Eight steps. By the time Employee Records reads &lt;code&gt;x-ms2-user&lt;/code&gt;, that value has passed through header stripping, credential validation, route authorization, token signing, sidecar validation, and peer binding. The service receives a narrow identity contract and passes that identity into PostgreSQL for row-level enforcement.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next-1&quot;&gt;What's Next&lt;/h3&gt;
&lt;p&gt;The next post covers the signing infrastructure: why Vault Transit instead of local keys, how key rotation works without downtime, and what the blast radius looks like when different components are compromised.&lt;/p&gt;
&lt;h3 id=&quot;code-1&quot;&gt;Code&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&quot;&gt;https://github.com/Kaustav-Sarkar/Istio-Defense-In-Depth&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26025/the-identity-flow-from-gateway-to-database</guid>
<pubDate>Tue, 01 Sep 2026 22:13:32 +0000</pubDate>
</item>
<item>
<title>Demystifying Connection Pooling: How to Stop Crashing Your Database</title>
<link>https://coderlegion.com/25268/demystifying-connection-pooling-how-to-stop-crashing-your-database</link>
<description>&lt;p&gt;Connection pooling is a performance-optimization technique used in software development to keep a cache of active database connections ready for reuse. Instead of establishing a brand-new connection and shutting it down every time an application needs to talk to a database, it borrows an active connection from a pre-established &quot;pool.&quot; This significantly reduces the time, energy, and server overhead associated with opening and closing communication channels on the fly.&lt;/p&gt;
&lt;h3 id=&quot;a-real-world-analogy-the-airport-taxi-stand&quot;&gt;A Real-World Analogy: The Airport Taxi Stand&lt;/h3&gt;
&lt;p&gt;To understand how this works, imagine a busy taxi stand outside a bustling airport terminal. Instead of manufacturing a brand-new car every single time a passenger walks out of the terminal and then destroying that car once they reach their destination, a fleet of taxis is kept parked and waiting at the stand.&lt;/p&gt;
&lt;p&gt;When a passenger arrives, they immediately hop into an available taxi, take their trip, and the taxi returns to the stand to wait for the next passenger. In this scenario, the taxi stand is the connection pool, the taxis are the active database connections, and the passengers are the individual data requests sent by your application. Keeping a fixed fleet of vehicles running is exponentially faster and less wasteful than creating and destroying cars for every single trip.&lt;/p&gt;
&lt;h3 id=&quot;why-connection-pooling-matters-daily-in-tech&quot;&gt;Why Connection Pooling Matters Daily in Tech&lt;/h3&gt;
&lt;p&gt;In the tech industry, connection pooling is critical for maintaining application speed and system stability. Creating a database connection is an &quot;expensive&quot; operation in computing terms because it requires performing complex network handshakes, validating security credentials, and allocating physical memory on both the application server and the database server.&lt;/p&gt;
&lt;p&gt;Without pooling, if ten thousand users visit a popular website simultaneously, the server would try to open ten thousand unique database connections at the exact same moment. This behavior can quickly exhaust the database's hardware resources, causing the database to crash and leaving users with broken pages or infinite loading spinners. By utilizing a connection pool, software engineers cap the maximum number of active connections to a safe limit (like 20 or 50), protecting the database from overloading while dramatically speeding up response times for everyday users.&lt;/p&gt;
&lt;h3 id=&quot;connection-pooling-in-action&quot;&gt;Connection Pooling in Action&lt;/h3&gt;
&lt;p&gt;Below is a simplified example in JavaScript using a PostgreSQL database library. It highlights the difference between manually managing individual client connections and utilizing an automatic connection pool.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Scenario A: Without Connection Pooling (Slow and Fragile)
import { Client } from 'pg';

async function getUserWithoutPool(userId) {
  const client = new Client({ connectionString: 'postgresql://db' });
  await client.connect(); // Manually opens a brand-new connection
  
  const result = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
  
  await client.end(); // Manually closes the connection
  return result.rows[0];
}

// Scenario B: With Connection Pooling (Fast and Robust)
import { Pool } from 'pg';
// Creates a reusable pool with a maximum limit of 10 connections
const pool = new Pool({ connectionString: 'postgresql://db', max: 10 });

async function getUserWithPool(userId) {
  // Automatically borrows a connection, queries the database, and
  // returns the connection back to the pool when finished
  const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
  return result.rows[0];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-key-takeaway&quot;&gt;The Key Takeaway&lt;/h3&gt;
&lt;p&gt;Ultimately, connection pooling highlights a fundamental rule of scalable system architecture: recycling high-cost resources is almost always faster and safer than rebuilding them from scratch. By treating database connections as a shared, reusable fleet rather than single-use assets, engineering teams ensure their software remains highly resilient, cost-effective, and lightning-fast under pressure, preventing catastrophic database meltdowns during unexpected traffic spikes.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/08/why-your-database-needs-connection.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/saurav_tb_pandey/demystifying-connection-pooling-how-to-stop-crashing-your-database-5bec&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25268/demystifying-connection-pooling-how-to-stop-crashing-your-database</guid>
<pubDate>Tue, 01 Sep 2026 12:57:32 +0000</pubDate>
</item>
<item>
<title>Three Steps That Decide Whether Plain English Database Queries Actually Work</title>
<link>https://coderlegion.com/25884/three-steps-that-decide-whether-plain-english-database-queries-actually-work</link>
<description>&lt;p&gt;Most of us here have either built a text-to-SQL layer or been asked to. The demos always look great and the production version is the one that quietly returns the wrong number, so here is what actually separates the two.&lt;/p&gt;
&lt;h3 id=&quot;the-schema-is-the-feature-not-the-prompt&quot;&gt;The Schema Is The Feature, Not The Prompt&lt;/h3&gt;
&lt;p&gt;A natural language query layer is not a prompt engineering problem. When you connect a database, the model has to read the whole schema first: every table, every column, every data type, every foreign key. That is what lets it decide which tables to touch, which columns to filter on, and how to join them.&lt;/p&gt;
&lt;p&gt;Skip that step and you still get SQL back. It runs, it returns rows, and it is wrong in a way nobody catches, because the join went through the wrong key. The systems that hold up are the ones that expose the generated SQL so a human can spot-check it, and that read relationships rather than guessing them from column names. There is a longer walkthrough of how that works in practice on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.aiappsapi.com/articles/aidatabases/naturallanguagequery.php&quot;&gt;asking your database questions in plain English&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;dirty-data-breaks-it-before-the-model-does&quot;&gt;Dirty Data Breaks It Before The Model Does&lt;/h3&gt;
&lt;p&gt;The second failure mode is upstream. Names stored three different ways, phone numbers in four formats, the same customer entered twice with a typo, blank fields where a required value should be. Your query is correct and your answer is still wrong, because COUNT DISTINCT on a column with fuzzy duplicates is not counting what you think.&lt;/p&gt;
&lt;p&gt;This is the least glamorous part and it is where most of the accuracy lives. Fuzzy matching to find near-duplicate records, normalizing formats across the table, validating each field against the rules it should obey, flagging rows with missing required values instead of silently averaging around them. A practical rundown is in &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.aiappsapi.com/articles/aidatabases/cleandata.php&quot;&gt;cleaning data with AI&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;let-the-model-find-what-you-did-not-ask-about&quot;&gt;Let The Model Find What You Did Not Ask About&lt;/h3&gt;
&lt;p&gt;The third step is the one teams skip entirely. Every query you write encodes something you already suspected. Anomaly detection covers the rest: point anomalies like an order fifty times the average, contextual ones like revenue that is normal for a retailer and impossible for a two person consultancy, and collective ones where ten individually boring transactions land in the same minute.&lt;/p&gt;
&lt;p&gt;That last category is the argument for running it at all, because no dashboard you would have thought to build catches it. Worth reading &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.aiappsapi.com/articles/dataanalysis/anomalydetection.php&quot;&gt;how AI anomaly detection actually runs&lt;/a&gt; if you are adding this to an existing stack.&lt;/p&gt;
&lt;h3 id=&quot;the-takeaway-6&quot;&gt;The Takeaway&lt;/h3&gt;
&lt;p&gt;Plain English querying is not one feature, it is three: clean the data, give the model the real schema, then let it surface what your questions never covered. Ship it in that order and the demo and the production system finally agree with each other.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25884/three-steps-that-decide-whether-plain-english-database-queries-actually-work</guid>
<pubDate>Mon, 31 Aug 2026 02:43:48 +0000</pubDate>
</item>
<item>
<title>Demystifying the N+1 Query Problem: Why Your App is Secretly Slow</title>
<link>https://coderlegion.com/25628/demystifying-the-n-1-query-problem-why-your-app-is-secretly-slow</link>
<description>&lt;h4 id=&quot;what-is-the-n-1-query-problem-&quot;&gt;What is the N+1 Query Problem?&lt;/h4&gt;
&lt;p&gt;The N+1 query problem is a performance bottleneck that occurs when an application makes database requests in an inefficient loop. Instead of fetching all necessary data in a single, organized request, the application makes one initial request (the &quot;1&quot;) followed by a separate request for every single item returned (the &quot;N&quot;). This results in a massive wave of unnecessary database communication that slows down applications.&lt;/p&gt;
&lt;h4 id=&quot;a-relatable-analogy&quot;&gt;A Relatable Analogy&lt;/h4&gt;
&lt;p&gt;Imagine you are a middle school teacher who needs to check if all 25 students in your classroom brought their signed field trip permission slips. &lt;/p&gt;
&lt;p&gt;Instead of asking the entire class to raise their hands or collecting all the slips in one basket (a single, optimized request), you walk up to the first student's desk and ask, &quot;Did you bring your slip?&quot;, and then walk all the way back to your desk to write down the answer. Then, you walk back out to the second student's desk, ask them, and walk back to your desk. You repeat this entire process 25 separate times.&lt;/p&gt;
&lt;p&gt;The &quot;1&quot; is your initial step of looking at your student roster. The &quot;N&quot; (25) represents the individual, repetitive, time-consuming trips you make back and forth across the room to get the details. It is an exhausting, highly inefficient waste of physical effort and time.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-daily-in-tech-1&quot;&gt;Why It Matters Daily in Tech&lt;/h4&gt;
&lt;p&gt;In real-world software, every single trip to a database—a specialized system that stores and organizes app data—takes time. Data has to travel across a network, which introduces latency (the delay before data transfer begins).&lt;/p&gt;
&lt;p&gt;When engineers build everyday features like social media feeds, e-commerce shopping carts, or analytics dashboards, they often use developer tools called Object-Relational Mappers (ORMs) to write code instead of raw database commands. If left unchecked, these tools automatically generate the N+1 query pattern. For example, if your feed displays 50 posts, the system will fire 1 query to get the posts, and then 50 separate, rapid-fire queries to look up the author for each individual post.&lt;/p&gt;
&lt;p&gt;Software engineers must actively detect and prevent this because it overwhelms database servers, drives up CPU usage, increases cloud hosting bills, and makes web pages load painfully slowly for end-users, especially under heavy traffic.&lt;/p&gt;
&lt;h4 id=&quot;the-concept-in-code&quot;&gt;The Concept in Code&lt;/h4&gt;
&lt;p&gt;Here is a simple example written in JavaScript, comparing the slow N+1 approach with the optimized solution. This example uses a mock database helper representing what happens under the hood of a typical web server.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// THE PROBLEM: The N+1 Query Approach
async function getBooksWithAuthorsBad() {
  // 1. Fetch all 50 books from the database (This is the &quot;1&quot; query)
  const books = await database.getBooks();

  for (const book of books) {
    // 2. Fetch the author details for EACH book individually (These are the &quot;N&quot; queries)
    // If there are 50 books, this block executes 50 separate times!
    book.author = await database.getAuthorById(book.authorId);
  } 
  return books;
}

// THE SOLUTION: Eager Loading (1 Query Total)
async function getBooksWithAuthorsGood() {
  // Fetch all books AND their corresponding authors in a single, combined database call
  // The database engine joins the tables together efficiently before sending data back
  const booksWithAuthors = await database.getBooks({ include: ['authors'] });
  return booksWithAuthors;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-takeaway-7&quot;&gt;The Takeaway&lt;/h4&gt;
&lt;p&gt;Eliminating the N+1 query problem is not about writing faster code; it is about communicating with your data storage system more respectfully. By shifting from a repetitive loop-based mindset to a batch-based retrieval mindset, you stop wasting precious network cycles and processing power. This simple structural change keeps your applications lightning-fast, prevents database crashes, and ensures a seamless experience for your users as your platform grows.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;resources-2&quot;&gt;Resources&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Saurav-TB-Pandey/react-hook-lab&quot;&gt;react-hook-lab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;react-hook-lab:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/react-hook-lab&quot;&gt;npm package&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connect with me on LinkedIn:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/pandeysaurav/&quot;&gt;Saurav Pandey&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/08/stop-tripping-over-your-database.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25628/demystifying-the-n-1-query-problem-why-your-app-is-secretly-slow</guid>
<pubDate>Fri, 28 Aug 2026 21:39:45 +0000</pubDate>
</item>
<item>
<title>I Moved My Next.js Dashboard Logic Into Postgres. My Frontend Got Boring (And That's the Point).</title>
<link>https://coderlegion.com/25362/i-moved-my-next-js-dashboard-logic-into-postgres-my-frontend-got-boring-and-thats-the-point</link>
<description>&lt;p&gt;My dashboard had a &lt;code&gt;useMemo&lt;/code&gt; doing arithmetic it had no business doing.&lt;/p&gt;
&lt;p&gt;It was a Pokémon TCG Pocket collection tracker, but that part doesn't matter. What matters is that the home page needed to show three things: overall completion, completion per set, and which set you were closest to finishing. The way I'd built it, the browser was fetching every card and every owned record, then grinding through the math on each render to figure all of that out.&lt;/p&gt;
&lt;p&gt;It worked. It also got slower and harder to read every time the data grew or I added a metric. So I moved the aggregation out of React and into Postgres, and the surprising result was that my frontend got &lt;em&gt;boring&lt;/em&gt;. Fewer hooks, less state, almost nothing left to break.&lt;/p&gt;
&lt;p&gt;That's the whole argument of this post: aggregation belongs in the database, and when you put it there, the React code that's left over is the kind of boring you actually want in the layer your users touch.&lt;/p&gt;
&lt;h3 id=&quot;what-fetching-everything-into-react-actually-looks-like&quot;&gt;What &quot;fetching everything into React&quot; actually looks like&lt;/h3&gt;
&lt;p&gt;Here's the shape of the original dashboard. Load all the cards, load the user's owned rows, then derive everything on the client.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const [cards, setCards] = useState&amp;lt;Card[]&amp;gt;([]);
const [owned, setOwned] = useState&amp;lt;OwnedCard[]&amp;gt;([]);

useEffect(() =&amp;gt; {
  (async () =&amp;gt; {
    const { data: allCards } = await supabase.from(&quot;cards&quot;).select(&quot;*&quot;);
    const { data: ownedRows } = await supabase
      .from(&quot;user_cards&quot;)
      .select(&quot;card_id&quot;);
    setCards(allCards ?? []);
    setOwned(ownedRows ?? []);
  })();
}, []);

const ownedIds = useMemo(() =&amp;gt; new Set(owned.map((o) =&amp;gt; o.card_id)), [owned]);

const perSet = useMemo(() =&amp;gt; {
  const groups: Record&amp;lt;string, { total: number; have: number }&amp;gt; = {};
  for (const card of cards) {
    const g = (groups[card.set_id] ??= { total: 0, have: 0 });
    g.total += 1;
    if (ownedIds.has(card.id)) g.have += 1;
  }
  return groups;
}, [cards, ownedIds]);

const overall = useMemo(() =&amp;gt; {
  const total = cards.length;
  const have = cards.filter((c) =&amp;gt; ownedIds.has(c.id)).length;
  return total ? have / total : 0;
}, [cards, ownedIds]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's not &lt;em&gt;bad&lt;/em&gt; code. But look at what it's costing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The payload grows with the collection.&lt;/strong&gt; I'm shipping every card row to the browser just to count them. The user never sees most of that data. It exists only to be reduced to a few numbers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The logic can't be tested without mounting a component.&lt;/strong&gt; The &quot;closest set to finishing&quot; rule lives inside hooks, tangled up with fetching and state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every new metric is more client state.&lt;/strong&gt; Want a &quot;rarest card you're missing&quot; stat? That's another loop, another &lt;code&gt;useMemo&lt;/code&gt;, another thing that re-runs on render.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I was using React as a database query engine. It's not one.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-the-wrong-layer&quot;&gt;Why this is the wrong layer&lt;/h3&gt;
&lt;p&gt;Counting, grouping, and computing ratios across rows is a set operation. It's the thing relational databases were built to do, indexed and compiled, sitting right next to the data.&lt;/p&gt;
&lt;p&gt;When I do it in React instead, I pay for the same work twice. Once to serialize every row and send it over the wire, and again to recompute the aggregates in JavaScript on the client. The database already knew the answer was &quot;you own 41 of 86 cards in this set.&quot; I just declined to ask it that question and asked for all 86 rows instead.&lt;/p&gt;
&lt;p&gt;This isn't really a performance micro-optimization. It's a question of which layer owns which job. Aggregation is a database job. I'd put it in the wrong place.&lt;/p&gt;
&lt;h3 id=&quot;moving-it-into-an-rpc-function&quot;&gt;Moving it into an RPC function&lt;/h3&gt;
&lt;p&gt;Supabase lets you call Postgres functions directly with &lt;code&gt;rpc()&lt;/code&gt;, so I wrote one that returns exactly the rows the dashboard needs: one row per set, with the counts already computed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create or replace function public.get_home_set_completion_rows()
returns table (
  set_id text,
  set_name text,
  set_slug text,
  release_date date,
  image_url text,
  data_version text,
  total_cards integer,
  owned_cards integer,
  missing_cards integer
)
language sql
stable
security invoker
set search_path = public
as \(
  select
    sets.id as set_id,
    sets.name as set_name,
    sets.slug as set_slug,
    sets.release_date,
    sets.image_url,
    sets.data_version,
    count(cards.id)::integer as total_cards,
    count(user_cards.card_id)::integer as owned_cards,
    (count(cards.id) - count(user_cards.card_id))::integer as missing_cards
  from public.sets
  left join public.cards
    on cards.set_id = sets.id
  left join public.user_cards
    on user_cards.card_id = cards.id
    and user_cards.user_id = auth.uid()
  group by
    sets.id,
    sets.name,
    sets.slug,
    sets.release_date,
    sets.image_url,
    sets.data_version
  order by
    sets.release_date desc nulls last,
    sets.id;
\);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;left join&lt;/code&gt; against &lt;code&gt;user_cards&lt;/code&gt; filtered by &lt;code&gt;auth.uid()&lt;/code&gt; is doing the per-user work right inside the query. Sets the user has zero cards from still show up with &lt;code&gt;owned = 0&lt;/code&gt;, which is what I want.&lt;/p&gt;
&lt;p&gt;And the call site collapses to this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { data } = await supabase.rpc(&quot;get_home_set_completion_rows&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. No fetching everything, no client-side grouping, no &lt;code&gt;Set&lt;/code&gt; of owned IDs. The component receives compact, purpose-built rows and maps over them. Overall completion is a sum of two columns. &quot;Closest to finishing&quot; is a sort. Both are now trivial because the hard part already happened in the database.&lt;/p&gt;
&lt;p&gt;The React file went from a pile of hooks to a Server Component that calls one function and renders the result. That's the &quot;my frontend got boring&quot; moment, and deleting all that state felt great.&lt;/p&gt;
&lt;h3 id=&quot;when-a-view-beats-a-function-and-vice-versa-&quot;&gt;When a view beats a function (and vice versa)&lt;/h3&gt;
&lt;p&gt;I reached for an RPC function here because of that &lt;code&gt;auth.uid()&lt;/code&gt; filter, since the result depends on who's asking. But it's worth knowing when a plain view is the better tool:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Use a view&lt;/strong&gt; when the aggregation is stable, reusable, and doesn't need parameters. A view reads like a table, composes into other queries, and is the simplest thing that works. Card counts per set that &lt;em&gt;aren't&lt;/em&gt; user-specific would be a great view.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use a function&lt;/strong&gt; when you need arguments (a user ID, a set filter, a scope toggle) or multi-step logic that a single &lt;code&gt;select&lt;/code&gt; can't express cleanly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One footgun worth flagging: both views and functions interact with row-level security, and &lt;code&gt;security definer&lt;/code&gt; versus &lt;code&gt;security invoker&lt;/code&gt; decides whose permissions the code runs under. A &lt;code&gt;security definer&lt;/code&gt; function bypasses the caller's RLS, which is occasionally what you want and frequently a way to leak other users' data by accident. If you're using RLS to protect per-user rows, and you should be, make sure your function respects it rather than quietly stepping around it.&lt;/p&gt;
&lt;h3 id=&quot;the-objections-worth-taking-seriously-&quot;&gt;The objections worth taking seriously.&lt;/h3&gt;
&lt;p&gt;A few reasonable complaints, because this trade isn't free.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Now my logic is split across two languages and harder to grep.&quot;&lt;/strong&gt; True. I moved complexity, I didn't delete it. The aggregation now lives in SQL instead of TypeScript, so there's a context switch to read the whole story. My argument is just that it lands in the &lt;em&gt;right&lt;/em&gt; place, with the database owning database work, and that the SQL version is shorter and more declarative than the loops it replaced.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;SQL is harder to test.&quot;&lt;/strong&gt; I'd push back on this one. The function is deterministic: give it fixture rows and it produces the same counts every time, with no component to mount. And separating it out made the logic that &lt;em&gt;correctly&lt;/em&gt; stayed in TypeScript easier to test too. My pack-recommendation engine, which sums pull probabilities for missing cards and ranks packs by expected value, is real application logic, not aggregation, so it belongs in app code. Once it wasn't tangled up with fetching and dashboard math, I could unit-test it on its own. The point isn't &quot;move everything into Postgres.&quot; It's &quot;move the aggregation, keep the decisions.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;This is just more infrastructure to manage.&quot;&lt;/strong&gt; Fair, but it's version-controlled SQL living next to the rest of the schema, not a magic black box. It migrates like everything else.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-of-thumb&quot;&gt;The rule of thumb&lt;/h3&gt;
&lt;p&gt;If you take one thing from this post, take this:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Push aggregation and filtering down to the database. Keep decisions and presentation in the app.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Or, more bluntly: if React is looping to count things, that loop probably wants to be a &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The frontend got boring, with smaller payloads, fewer hooks, and less state to corrupt, and boring is exactly what you want from the layer your users actually touch. The interesting, opinionated, easy-to-get-wrong logic moved to where it could be expressed simply and tested in isolation.&lt;/p&gt;
&lt;p&gt;The genuinely contested part is &lt;em&gt;where you draw the line&lt;/em&gt;. I keep ranking and business decisions in app code and push counting and filtering into the database, but I know people who'd move more in either direction. Where do you draw it?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Full code, if you want to see the whole thing wired together: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mwiginton/pocketdex-tracker&quot;&gt;mwiginton/pocketdex-tracker&lt;/a&gt;. Or try the live app: &lt;a rel=&quot;nofollow&quot; href=&quot;https://pocketdex-tracker.vercel.app/auth/login&quot;&gt;pocketdex-tracker.vercel.app&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25362/i-moved-my-next-js-dashboard-logic-into-postgres-my-frontend-got-boring-and-thats-the-point</guid>
<pubDate>Tue, 25 Aug 2026 16:16:35 +0000</pubDate>
</item>
<item>
<title>Continuous Delivery Office Hours Ep.5: Delivering database changes</title>
<link>https://coderlegion.com/25292/continuous-delivery-office-hours-ep-5-delivering-database-changes</link>
<description>&lt;p&gt;In the previous episode, we talked about &lt;a rel=&quot;nofollow&quot; href=&quot;/blog/continuous-delivery-office-hours-e4&quot;&gt;different service design approaches&lt;/a&gt;. This time, we dive deeper into database changes.&lt;/p&gt;
&lt;p&gt;Even for teams that can effortlessly deploy their application code, database changes can be more stressful. Changing a schema is a high-stakes operation, and there are many ways to do it badly.&lt;/p&gt;
&lt;p&gt;Read on to find out why database deployments are different from application deployments, and what techniques you can use to make them worry-free.&lt;/p&gt;
&lt;h3 id=&quot;watch-the-episode&quot;&gt;Watch the episode&lt;/h3&gt;
&lt;p&gt;You can watch the episode below, or read on to find some of the key discussion points.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=_rHyP_RZbQc&quot;&gt;Watch Continuous Delivery Office Hours Ep.5&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-are-databases-different&quot;&gt;Why are databases different&lt;/h3&gt;
&lt;p&gt;When you change your application's code and discover an issue, it's trivial to revert to the previous version. In rare cases where a change affects your data, there may be cleanup to do.&lt;/p&gt;
&lt;p&gt;You can use timestamps and a one-off process to fix those rare data-mess-up instances, but they hint at something fundamentally different about database updates. They have different levels and types of risk associated with them.&lt;/p&gt;
&lt;p&gt;If you think backups will save you from a bad database deployment, you haven't yet tried it. Sure, they prevent total data loss, but between your backup and your fix, the data moved. Often by a lot.&lt;/p&gt;
&lt;p&gt;There are techniques to apply transactions from the backup up to the point of failure, but if your change caused an issue you needed to roll back, you probably don't want to apply those transactions automatically. Welcome to the &quot;data remediation project&quot;.&lt;/p&gt;
&lt;p&gt;If you added a new column or table as part of your database change and you need to roll back, you have to decide what to do with any data in those tables. Do you forget it, or do you need to keep hold of it and reapply it later when you make a new attempt to extend the schema?&lt;/p&gt;
&lt;p&gt;Modern software teams prefer fix-forward for application issues, which are reasonably easy to roll back. Databases take rollbacks to another level.&lt;/p&gt;
&lt;h3 id=&quot;crucial-modernization-steps&quot;&gt;Crucial modernization steps&lt;/h3&gt;
&lt;p&gt;Imagine you met a friend for lunch and they told you they store they application's source code on a network share, rather than in version control. You'd think it was a joke, and when you realize it's not, you'd form a strong opinion about the kind of sloppy outfit they must be running.&lt;/p&gt;
&lt;p&gt;When we meet for lunch, what are you going to tell me about your database schema and static data? Please tell me it's all in version control, not on a network share.&lt;/p&gt;
&lt;p&gt;You should make all database changes by updating the files in version control and deploying them like you would your application code. You progress the change through environments to ensure it works, and you avoid embarrassment caused by the application failing because someone forgot to add the new column in production.&lt;/p&gt;
&lt;p&gt;There are further choices to make, which we'll cover next, but failing to version-control your database is unforgivable.&lt;/p&gt;
&lt;h3 id=&quot;state-based-vs-migration-based-approaches&quot;&gt;State-based vs migration-based approaches&lt;/h3&gt;
&lt;p&gt;On to the first choice for your database project. Do you make state-based or migration-based updates?&lt;/p&gt;
&lt;p&gt;State-based schemas describe the desired state of the database. It will list each table with its columns, indexes, and relationships. You use a model-based tool to deploy the database, which compares your current state with the desired state and applies the changes for you.&lt;/p&gt;
&lt;p&gt;Some state-based tools convert the differences into standard database scripts, like &lt;code&gt;ALTER TABLE...&lt;/code&gt; scripts. Others perform migrations by creating a new table with the changes and moving the data into it. This is important if you're using a technology like replication, which prevents the model migration mechanism from working.&lt;/p&gt;
&lt;p&gt;The alternative to state-based database updates is migration-based updates, where you write your own &lt;code&gt;ALTER TABLE&lt;/code&gt; scripts. You keep all your scripts in version control and use a tool that applies them in order and tracks when each was applied, preventing the same script from being applied twice to the same database.&lt;/p&gt;
&lt;p&gt;The main difference between the two is procedural. You can code-review the migration scripts on demand, but you'll need to review state-based migration scripts after your tools generate the implementation plan, which can make the review task more of a large batch.&lt;/p&gt;
&lt;h3 id=&quot;tooling-and-automation&quot;&gt;Tooling and automation&lt;/h3&gt;
&lt;p&gt;Whichever approach you use, tooling helps it work. Many of our customers use Redgate tools as part of their deployment process to manage database schema changes, and there's value in leaning on a tool written by folks who care deeply about the problem and how to solve it.&lt;/p&gt;
&lt;p&gt;Crucially, you shouldn't make any database changes outside of tool-based automation.&lt;/p&gt;
&lt;h3 id=&quot;test-data-management&quot;&gt;Test data management&lt;/h3&gt;
&lt;p&gt;Once you've automated your database schema and static data, it's worth considering your test data. When automated or manual acceptance tests fail, it's usually because someone unwittingly messed up the data. Prior test runs often leave data in an inconsistent state, especially if a test failure halts the run.&lt;/p&gt;
&lt;p&gt;You can resolve this issue by automating your test data setup. Not only does this make it easy to reset the data during your build and test cycle, but it also lets you provide a self-service runbook for the test team to reset the data in their test environment whenever they need to.&lt;/p&gt;
&lt;p&gt;There's an up-front investment in this, but I can promise that it takes fewer hours than fixing your test data a few times.&lt;/p&gt;
&lt;h3 id=&quot;database-refactoring-patterns&quot;&gt;Database refactoring patterns&lt;/h3&gt;
&lt;p&gt;The final thought to ponder concerns the steps you take in changing your database schema. When you're in the habit of deploying your database and application in the same release process, you start to depend on this change coordination.&lt;/p&gt;
&lt;p&gt;You delete a column from the database and immediately deploy the application, with all references to the deleted column removed. It looks like it works smoothly, but it's a trap.&lt;/p&gt;
&lt;p&gt;Imagine you had a critical bug in the application and had to redeploy the previous version. Now you can't, because the previous version will try to read from a column that doesn't exist. You no longer have a quick, easy back out plan, since you have to re-add the column, and you'll also need data to put in it.&lt;/p&gt;
&lt;p&gt;This approach also prevents you from making seamless deployments, as even if you progressively roll out the application version, the old version will error out due to the database change. You may deploy in the opposite order, application first, then database. You'll discover the same problem with new columns that the latest version expects to find in the database.&lt;/p&gt;
&lt;p&gt;You need to decouple database and application deployments, and there's a whole book on the topic, called Refactoring Databases (Ambler, Sadalage). You can start by following the expand/contract pattern, which splits updates into steps. The principle is that you don't delete a column until the production application has no reads or writes. You add a column and don't reference it in your code until you deploy to production.&lt;/p&gt;
&lt;p&gt;This means you can run the current version and the new version of the software against the same database, which means you can progressively roll out the new version and redeploy the prior version without touching the database.&lt;/p&gt;
&lt;h3 id=&quot;databases-are-only-as-hard-as-you-make-them&quot;&gt;Databases are only as hard as you make them&lt;/h3&gt;
&lt;p&gt;The database is high-risk, which is why updating it can be scary. I hope you've found this post full of practical advice for making database deployments robust and stress-free.&lt;/p&gt;
&lt;p&gt;Database deployments, like application deployments, should be a happy time. You should be celebrating the new features and enhancements you've delivered to your users, not biting your nails and worrying that something's about to go horribly wrong.&lt;/p&gt;
&lt;p&gt;Happy deployments!&lt;/p&gt;
&lt;p&gt;Continuous Delivery Office Hours is a series of conversations about software delivery, with Tony Kelly, Bob Walker, and Steve Fenton.&lt;/p&gt;
&lt;p&gt;You can find more episodes on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/playlist?list=PLAGskdGvlaw3CrxkUOAMmiy928lr5D4oh&quot;&gt;YouTube&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://podcasts.apple.com/us/podcast/continuous-delivery-office-hours/id1872101651&quot;&gt;Apple Podcasts&lt;/a&gt;, and &lt;a rel=&quot;nofollow&quot; href=&quot;https://pca.st/hwjaox59&quot;&gt;Pocket Casts&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25292/continuous-delivery-office-hours-ep-5-delivering-database-changes</guid>
<pubDate>Mon, 24 Aug 2026 12:57:43 +0000</pubDate>
</item>
<item>
<title>Parallel Processing Gone Wrong</title>
<link>https://coderlegion.com/25281/parallel-processing-gone-wrong</link>
<description>&lt;p&gt;⚡ &quot;Let's Make It Faster!&quot; (Famous Last Words)&lt;/p&gt;
&lt;p&gt;The Scene: Sprint planning. The feature? Bulk data processing.&lt;br&gt;
Me (naive): &quot;Let's just run everything in parallel! It'll be 10x faster!&quot;&lt;br&gt;
Production (3 days later): catches fire &lt;br&gt;
What I Thought Would Happen:&lt;br&gt;
•	100 items to process&lt;br&gt;
•	Run them all in parallel&lt;br&gt;
•	Finish in 1/10th the time&lt;br&gt;
•	Get promoted&lt;br&gt;
•	Become CTO&lt;br&gt;
What Actually Happened:&lt;br&gt;
•	100 concurrent database connections&lt;br&gt;
•	Database connection pool exhausted&lt;br&gt;
•	ALL requests start failing (not just ours)&lt;br&gt;
•	2 AM phone call from the CTO (not a promotion call)&lt;br&gt;
•	Emergency rollback at 3 AM&lt;br&gt;
•	Very awkward Slack conversation&lt;br&gt;
The Broken Code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// &quot;I'll just parallelize everything!&quot;
foreach (var item in items) {
    Task.Run(() =&amp;gt; ProcessItem(item)); // YOLO
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why This Implodes:&lt;br&gt;
❌ No limit on concurrent operations&lt;br&gt;
❌ No shared state protection&lt;br&gt;
❌ No error handling per item&lt;br&gt;
❌ Overwhelms downstream services&lt;br&gt;
❌ Database can't handle the connections&lt;br&gt;
❌ Network ports get exhausted&lt;br&gt;
❌ And my ego gets crushed&lt;br&gt;
The Fix That Saved My Job:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;int passCount = 0;
int failCount = 0;
var errors = new ConcurrentDictionary&amp;lt;string, string&amp;gt;();

// Key: MaxDegreeOfParallelism controls concurrency
Parallel.For(0, items.Count, 
    new ParallelOptions { 
        MaxDegreeOfParallelism = Environment.ProcessorCount // Usually 4-8
    }, 
    (i) =&amp;gt; {
        try {
            ProcessItem(items[i]);
            
            // Thread-safe counter increment
            Interlocked.Increment(ref passCount);
        } 
        catch (Exception ex) {
            Interlocked.Increment(ref failCount);
            
            // Thread-safe error collection
            errors.TryAdd(i.ToString(), ex.Message);
        }
    });

// Now you can safely check results
Console.WriteLine($&quot;Success: {passCount}, Failed: {failCount}&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Golden Rules I Learned:&lt;br&gt;
1️⃣ Parallelism ≠ Performance - Too much parallelism kills performance&lt;br&gt;
2️⃣ Know Your Bottleneck - If it's I/O bound, 4-8 threads is usually optimal&lt;br&gt;
3️⃣ Protect Shared State - Use Interlocked or ConcurrentCollections&lt;br&gt;
4️⃣ Configure, Don't Hardcode - Make MaxDegreeOfParallelism environment-specific&lt;br&gt;
5️⃣ Test Under Load - It works on my machine ≠ it works in production&lt;br&gt;
The Results After Proper Implementation:&lt;br&gt;
 Controlled concurrency = predictable performance&lt;br&gt;
✅ Database connections stay healthy&lt;br&gt;
⚡ 6x speedup (not 100x, but sustainable)&lt;br&gt;
 Zero production incidents&lt;br&gt;
 Sleep peacefully without 3 AM alerts&lt;br&gt;
The Hard Truth:&lt;br&gt;
More threads ≠ More speed&lt;br&gt;
It's like adding more cooks to a kitchen. At some point, they just bump into each other.&lt;br&gt;
What's your parallel processing horror story? I can't be the only one who learned this the hard way &lt;/p&gt;
&lt;h2 id=&quot;dotnet-parallelprocessing-performanceoptimization-productionissues-softwareengineering-threading-scalability-lessonslearned-debugging-csharp-techleadership&quot;&gt;dotnet #parallelprocessing #performanceoptimization #productionissues #softwareengineering #threading #scalability #lessonslearned #debugging #csharp #techleadership&lt;/h2&gt;
&lt;hr&gt;
&lt;h3 id=&quot;p-s-if-you-re-using-task-run-in-a-loop-or-unbounded-parallel-foreach-you-re-one-production-deployment-away-from-a-very-bad-day-&quot;&gt;P.S. If you're using Task.Run() in a loop or unbounded Parallel.ForEach(), you're one production deployment away from a very bad day.&lt;/h3&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25281/parallel-processing-gone-wrong</guid>
<pubDate>Mon, 24 Aug 2026 10:38:39 +0000</pubDate>
</item>
</channel>
</rss>