<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged postgres</title>
<link>https://coderlegion.com/tag/postgres</link>
<description>Powered by Question2Answer</description>
<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>Cancel or send: one winner. An order flow on Prisma Postgres and Compute</title>
<link>https://coderlegion.com/32897/cancel-or-send-one-winner-an-order-flow-on-prisma-postgres-and-compute</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Werbung / Disclosure:&lt;/strong&gt; Prisma gave me a coupon for two months of a paid plan for free and said it will add free credits, so I could try &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.prisma.io/postgres&quot;&gt;Prisma Postgres&lt;/a&gt; and Compute for this project. All results below come from my own runs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;I built an order flow with a 10-minute grace period. Cancellation and dispatch must have exactly one winner in the database.&lt;/li&gt;
&lt;li&gt;The winner is decided in Postgres, with a guarded update, a row lock and &lt;code&gt;SKIP LOCKED&lt;/code&gt;. No clock and no timer decides it.&lt;/li&gt;
&lt;li&gt;I broke the code on purpose, one line at a time, to check that my tests really fail. One of my race tests passed even without the lock.&lt;/li&gt;
&lt;li&gt;On Compute, an idle app sleeps. My poller is a timer inside the server, so it only runs when someone calls the app. This changes the timing, not the correctness.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The problem&lt;/h3&gt;
&lt;p&gt;A customer orders a custom T-shirt. I promise a grace period of 10 minutes. During that time the customer can cancel. After that, the printer gets an email and the customer cannot cancel any more.&lt;/p&gt;
&lt;p&gt;Two things can happen at almost the same moment. The customer presses cancel, and my poller wants to send. Exactly one of them must win. A cancelled order must never reach the printer, because an email cannot be recalled.&lt;/p&gt;
&lt;h3 id=&quot;design-decisions&quot;&gt;Design decisions&lt;/h3&gt;
&lt;h4 id=&quot;claim-first-then-send&quot;&gt;Claim first, then send&lt;/h4&gt;
&lt;p&gt;The poller claims an order by changing its status from &lt;code&gt;PENDING&lt;/code&gt; to &lt;code&gt;SENT&lt;/code&gt; before it sends the email. If the customer cancels first, the claim finds nothing. If the claim wins, the cancel is rejected with a 409.&lt;/p&gt;
&lt;p&gt;Here, &lt;code&gt;SENT&lt;/code&gt; means the order has been claimed for dispatch, not that the email has been delivered. If the server crashes after the claim, the printer may get no email. I set &lt;code&gt;printerNotifiedAt&lt;/code&gt; only after Resend accepts it. A stuck-order query finds &lt;code&gt;SENT&lt;/code&gt; orders without that timestamp, and a resend route lets me recover them.Sending the email before claiming the order could allow it to be sent even if the cancellation wins the database race.&lt;/p&gt;
&lt;h4 id=&quot;a-guarded-transition&quot;&gt;A guarded transition&lt;/h4&gt;
&lt;p&gt;Every status change goes through one function. It checks that the transition is allowed for the actor. Then it writes with the old status in the where clause:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { count } = await prisma.order.updateMany({
  where: { id, status: order.status },
  data: { status: to },
});
if (count === 0) throw new ConflictException('Order changed, please retry');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If someone changed the row between my read and my write, &lt;code&gt;count&lt;/code&gt; is 0 and the caller gets a 409. This is the sixth time I used this read-then-write pattern across my projects.&lt;/p&gt;
&lt;h4 id=&quot;a-postgres-poller-not-a-queue&quot;&gt;A Postgres poller, not a queue&lt;/h4&gt;
&lt;p&gt;The hosted version has no Redis, so there is no BullMQ. The order and its deadline are written in one transaction. A timer loop claims due orders with one statement:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE &quot;Order&quot; SET status = 'SENT' -- plus updatedAt
WHERE id IN (
  SELECT id FROM &quot;Order&quot;
  WHERE status = 'PENDING' AND &quot;sendAt&quot; &amp;lt;= (now() AT TIME ZONE 'UTC')
  ORDER BY &quot;sendAt&quot; LIMIT 10
  FOR UPDATE SKIP LOCKED
)
RETURNING id
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is no enqueue gap, because the deadline is data. The costs are a polling delay and retry logic that I write myself. Today there is no automatic retry. The safety net is the stuck-order query and the resend route.&lt;/p&gt;
&lt;p&gt;The server owns &lt;code&gt;sendAt&lt;/code&gt;. A client-supplied value could skip the grace period, so the request schema rejects unknown keys.&lt;/p&gt;
&lt;h4 id=&quot;a-row-lock-on-the-design&quot;&gt;A row lock on the design&lt;/h4&gt;
&lt;p&gt;An order points at a design. If the design changes after the order exists, the printer prints something the customer never ordered, and nobody sees an error. Creating an order, updating a design and deleting a design all take &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on the design row first. A design counts as locked when it has any order that is not cancelled.&lt;/p&gt;
&lt;h3 id=&quot;tests-and-a-test-that-proved-nothing&quot;&gt;Tests, and a test that proved nothing&lt;/h3&gt;
&lt;p&gt;I wrote integration tests against a real Postgres database. Then I broke the code on purpose, one line at a time, and checked that a test turned red.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;I removed &lt;/th&gt;&lt;th&gt;What went red&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;the status in the &lt;code&gt;updateMany&lt;/code&gt; guard &lt;/td&gt;&lt;td&gt;the cancel-versus-poller test, and all 30 printer/admin confirm iterations&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;status = 'PENDING'&lt;/code&gt; in the claim &lt;/td&gt;&lt;td&gt;a cancelled order was emailed, and two pollers sent two emails&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; &lt;/td&gt;&lt;td&gt;the locked-row test (the pollers waited)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; in order creation &lt;/td&gt;&lt;td&gt;only the deterministic lock test&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; in design update &lt;/td&gt;&lt;td&gt;only the deterministic lock test (65 passed, 1 failed)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; in design removal &lt;/td&gt;&lt;td&gt;the remove-versus-create test: a foreign key error instead of a 409&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The lock rows are the lesson. First I tested the lock with 30 free-running iterations of update versus create. They all passed with the lock removed. The broken interleaving ends in the same final database state as the harmless one, so no assertion on the final state can tell them apart. Only a test that pauses order creation after it takes the lock, and then checks whether the update can finish, sees the bug.&lt;/p&gt;
&lt;p&gt;A test also found a timezone bug. I ran the integration session in America/Los_Angeles. Comparing sendAt with a bare now() failed because sendAt is a timestamp without time zone. The comparison now uses now() AT TIME ZONE 'UTC', assuming sendAt is stored as a UTC timestamp. This fixes the timezone interpretation, but not clock skew between the application and database; I discuss that remaining limitation below.&lt;/p&gt;
&lt;h3 id=&quot;prisma-postgres-and-compute&quot;&gt;Prisma Postgres and Compute&lt;/h3&gt;
&lt;p&gt;Prisma ORM 8 (I write Prisma 8 for short) is a release candidate and Composer is in Early Access. Compute has been generally available since August 2026. Everything in this section was true on my machine in October 2026. Versions: Prisma CLI 8.0.0-rc.27, &lt;code&gt;@prisma/orm-postgres&lt;/code&gt; 8.0.0-rc.20, Composer 0.34.0.&lt;/p&gt;
&lt;h4 id=&quot;why-prisma-orm-8-is-in-this-project&quot;&gt;Why Prisma ORM 8 is in this project&lt;/h4&gt;
&lt;p&gt;The Console told me that my repository used &lt;code&gt;prisma ^7.7.0&lt;/code&gt;, that deploys run on Prisma 8, and that I should upgrade first. So I installed Prisma 8 next to Prisma 7, inferred a contract from my database, and planned a baseline migration. The plan had 15 operations from an empty database: four enums, four tables, the &lt;code&gt;(status, sendAt)&lt;/code&gt; index and four foreign keys.&lt;/p&gt;
&lt;p&gt;Later, Martin from Prisma told me that a Prisma 7 app runs fine on Compute and that Prisma 8 is not required. I did not test a Prisma 7 only deploy myself, so I cannot confirm it, and I reported the Console banner to Prisma.&lt;/p&gt;
&lt;p&gt;I kept Prisma 8 for one job: the deploy applies my baseline migration to the new database. My queries still run on Prisma 7 with the &lt;code&gt;pg&lt;/code&gt; adapter. The docs also describe a plainer option, &lt;code&gt;rawPostgres()&lt;/code&gt;, which hands your own client a &lt;code&gt;db.url&lt;/code&gt;. With it, you migrate the database yourself. I did not use it.&lt;/p&gt;
&lt;p&gt;I kept the queries on Prisma 7 for another reason. The release status page lists what Prisma 8 does not have yet, for example transaction isolation levels, atomic increment, most nested writes and &lt;code&gt;P2002&lt;/code&gt;-style error codes. My locks use raw SQL and a guarded &lt;code&gt;updateMany&lt;/code&gt;, and I did not try them on Prisma 8.&lt;/p&gt;
&lt;p&gt;Two more facts for Prisma 7 users. First, &lt;code&gt;npx prisma&lt;/code&gt; now runs the Prisma 8 CLI, which has different data commands (&lt;code&gt;contract&lt;/code&gt;, &lt;code&gt;db&lt;/code&gt; and &lt;code&gt;migration&lt;/code&gt;). Pin &lt;code&gt;prisma&lt;/code&gt; to &lt;code&gt;^7&lt;/code&gt; and use &lt;code&gt;npx prisma@7&lt;/code&gt;, or install an alias package like I did. Second, Prisma 8 is a release candidate, and the release status page expects general availability in October 2026.&lt;/p&gt;
&lt;h4 id=&quot;getting-a-nestjs-11-app-onto-compute&quot;&gt;Getting a NestJS 11 app onto Compute&lt;/h4&gt;
&lt;p&gt;Compute runs a single bundled file. My notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The deploy tool refused Node 22.23.4 and asked for Node 24.11.1 or newer. The Compute docs say Node 22.18 or newer on the 22 line is enough, so the docs and the tool disagree.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bcrypt&lt;/code&gt; is a native addon. Bundled into one file, it crashed at startup with &lt;code&gt;__dirname is not defined&lt;/code&gt;. I switched to &lt;code&gt;bcryptjs&lt;/code&gt;. Login and the tests still passed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prisma dev&lt;/code&gt; is not supported on Windows yet, so I tested the bundle with plain &lt;code&gt;node&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Composer needs &lt;code&gt;type: module&lt;/code&gt;. I put the Composer files in a &lt;code&gt;deploy&lt;/code&gt; folder with its own &lt;code&gt;package.json&lt;/code&gt;, so &lt;code&gt;nest build&lt;/code&gt; stays CommonJS. A small launcher reads the database URL that the platform provides and then starts the compiled app.&lt;/li&gt;
&lt;li&gt;The Console's setup pull request failed at &lt;code&gt;npm install&lt;/code&gt; with &lt;code&gt;ERESOLVE&lt;/code&gt;. I could reproduce a conflict on my machine by adding &lt;code&gt;alchemy&lt;/code&gt; next to Jest 30 (a Babel peer dependency clash). I could not see the Console's log, so I do not know if it is the same cause.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first deploy created ten resources, including the database and the migration step, and returned a public URL. The docs say Compute runs deployed apps on Bun, and I built my bundle for Node. The app worked when I tried it. The poller is switched off on that deployment, so orders stay &lt;code&gt;PENDING&lt;/code&gt; there.&lt;/p&gt;
&lt;h3 id=&quot;the-surprise-scale-to-zero-versus-my-poller&quot;&gt;The surprise: scale to zero versus my poller&lt;/h3&gt;
&lt;p&gt;My poller is a timer inside the server. I deployed a small test app with a counter that ticks every 5 seconds and shows its history. Then I left it alone overnight.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;startedAt&lt;/code&gt; value was the same after 15 hours, so the app was never restarted.&lt;/li&gt;
&lt;li&gt;Only 2 ticks were recorded. The first gap was 54,940 seconds.&lt;/li&gt;
&lt;li&gt;New ticks appeared at the moments I opened the page. Then they stopped again.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Compute docs describe this: idle instances sleep with a memory snapshot and resume in milliseconds. A timer inside the process only runs when someone calls the app.&lt;/p&gt;
&lt;p&gt;What still holds: one database claim wins, and a cancelled order cannot be claimed for dispatch through this flow. What breaks is the timing. The email goes out after the next request, not at minute 10. Exactly-once email delivery is not guaranteed; a crash during recovery can result in a duplicate.&lt;/p&gt;
&lt;h4 id=&quot;open-question-where-should-the-poller-run-&quot;&gt;Open question: where should the poller run?&lt;/h4&gt;
&lt;p&gt;Compute has no built-in cron scheduler. A Prisma engineer told me that the recommended patterns are options 2 and 3 below, and that keep-awake is meant for short, bounded work. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.prisma.io/docs/compute/limitations&quot;&gt;Compute limitations&lt;/a&gt; page and the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.prisma.io/docs/composer/building-blocks#cron&quot;&gt;Composer cron building block&lt;/a&gt; describe both. I have not chosen between them yet, and I would like to discuss it. These are the options I found:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Keep the instance awake.&lt;/strong&gt; Prisma says keep-awake is meant for short, bounded work, not for an always-on loop. The docs add that it does not guarantee continuity through restarts or deployments. I have not measured what a permanently awake instance costs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An external trigger (recommended by Prisma).&lt;/strong&gt; A scheduler outside Compute calls a protected route. The route wakes the app, runs the same claim logic and returns. My tests show that extra triggers are safe: two pollers claim an order once. The timing precision depends on the scheduler, and the request must start responding within 60 seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Composer cron building block (recommended by Prisma).&lt;/strong&gt; Its scheduler is a service that never scales to zero, so it costs one warm instance. Composer is in Early Access, and I did not find an example of mounting the cron runner in a NestJS app.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Questions that are still open:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does the always-warm cron scheduler count against the Free plan, and how much memory does it use?&lt;/li&gt;
&lt;li&gt;Has anyone mounted the cron runner inside a NestJS app?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you solved this, I would like to hear how.&lt;/p&gt;
&lt;h3 id=&quot;raw-output&quot;&gt;Raw output&lt;/h3&gt;
&lt;p&gt;These are trimmed outputs from my own runs.&lt;/p&gt;
&lt;p&gt;The deploy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;t-shirt-backend-test
├─ database   postgres-database db_...
└─ app        compute-service cps_...
Stack deployed (10/10)
Deployed t-shirt-backend-test to production in 3m 1s.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bundle with the native &lt;code&gt;bcrypt&lt;/code&gt; addon, at startup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ReferenceError: __dirname is not defined in ES module scope
    at ... require_node_gyp_build()(path.resolve(__dirname))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The test app's ticks, first call (15 hours after the deploy):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;startedAt&quot;:&quot;2026-10-25T16:36:23.113Z&quot;,&quot;now&quot;:&quot;2026-10-26T07:59:53.581Z&quot;,&quot;count&quot;:2,
 &quot;gaps&quot;:[{&quot;from&quot;:&quot;2026-10-25T16:42:26.838Z&quot;,&quot;to&quot;:&quot;2026-10-26T07:58:06.462Z&quot;,&quot;seconds&quot;:54940}]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second call, about 10 minutes later (the first gap is shortened):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;startedAt&quot;:&quot;2026-10-25T16:36:23.113Z&quot;,&quot;now&quot;:&quot;2026-10-26T08:10:05.860Z&quot;,&quot;count&quot;:4,
 &quot;gaps&quot;:[{&quot;seconds&quot;:54940},{&quot;from&quot;:&quot;2026-10-26T07:58:06.462Z&quot;,&quot;to&quot;:&quot;2026-10-26T07:59:53.591Z&quot;,&quot;seconds&quot;:107}]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two failures from the mutation runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;status guard removed, confirm race:   Expected length: 1   Received length: 2
design lock removed, lock-held test:  Expected: &quot;rejected&quot;  Received: &quot;fulfilled&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-i-did-not-verify&quot;&gt;What I did not verify&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;I did not run the poller on Compute under load, and I did not measure how late the emails are.&lt;/li&gt;
&lt;li&gt;I have not fixed clock skew between the app server and the database. &lt;code&gt;sendAt&lt;/code&gt; is written from the app clock and compared with the database clock, so skew changes the grace period silently.&lt;/li&gt;
&lt;li&gt;There is no automatic retry for failed emails.&lt;/li&gt;
&lt;li&gt;I did not port locks and transactions to Prisma 8. They stay on Prisma 7.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Let the database decide the winner. Do not let a timer or a clock decide it.&lt;/li&gt;
&lt;li&gt;Break your code on purpose. A test that also passes without the guard proves nothing.&lt;/li&gt;
&lt;li&gt;A free-running race loop can pass for the wrong reason. Force the interleaving.&lt;/li&gt;
&lt;li&gt;Scale to zero changes what a timer inside your server means. Check it before you design around it.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;I'm Mhamd, a backend engineer working with NestJS and TypeScript. Portfolio: &lt;a rel=&quot;nofollow&quot; href=&quot;https://mhamd021.github.io&quot;&gt;mhamd021.github.io&lt;/a&gt; · GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Mhamd021&quot;&gt;Mhamd021&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32897/cancel-or-send-one-winner-an-order-flow-on-prisma-postgres-and-compute</guid>
<pubDate>Wed, 28 Oct 2026 21:30:34 +0000</pubDate>
</item>
<item>
<title>A week of green runs, and the pipeline was waiting for me</title>
<link>https://coderlegion.com/32698/a-week-of-green-runs-and-the-pipeline-was-waiting-for-me</link>
<description>&lt;p&gt;On 1 September at 10:57 UTC the monthly ingest ran. It opened issue number 3 of the magazine, picked the vertical from the source registry, wrote 19 candidate sources into Postgres with &lt;code&gt;stage = 'discovery'&lt;/code&gt;, and opened a GitHub issue telling me it was my turn.&lt;/p&gt;
&lt;p&gt;Every morning after that, the daily job ran and succeeded. Eight consecutive green runs, from the first of the month to the eighth. The article count for September stayed at zero the whole time.&lt;/p&gt;
&lt;h3 id=&quot;the-gate-was-doing-its-job&quot;&gt;The gate was doing its job&lt;/h3&gt;
&lt;p&gt;The daily job does not decide anything by itself. It asks the database which stage to run, and the answer depends on a view:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, issue_id, source_url, tier, independent
FROM signals
WHERE stage = 'verify' AND (tier = 1 OR tier = 2 AND independent);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing satisfies that view until I sit in Supabase Studio and tag the sources by hand. Tier 1 is a primary source, a regulator or the organisation that ran the thing. Tier 2 counts only when it is independent of whoever it describes, which rules out a consultancy writing up its own project as a case study.&lt;/p&gt;
&lt;p&gt;That judgement is mine on purpose. It is the editorial bar of the magazine, and I did not want a model deciding what counts as proof of anything.&lt;/p&gt;
&lt;p&gt;So the decision function found a draft issue with no verified signals, returned &lt;code&gt;niente&lt;/code&gt;, and printed its reason to stderr:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;advance: signal in attesa di verifica in Studio
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then it exited zero, which is correct. There was nothing for it to do.&lt;/p&gt;
&lt;h3 id=&quot;what-my-alarms-actually-watch&quot;&gt;What my alarms actually watch&lt;/h3&gt;
&lt;p&gt;I have three, and I was fairly pleased with them.&lt;/p&gt;
&lt;p&gt;A cron that fails opens a GitHub issue on its own. A cron that never starts is caught by a daily watchman that asks the GitHub API when each schedule last fired, because a schedule that GitHub has quietly disabled produces no failure to report. And because a watchman inside the same failure domain is not a watchman, that job also checks in with a Sentry monitor that alarms on silence from outside GitHub.&lt;/p&gt;
&lt;p&gt;Between them they cover a job that breaks and a job that never runs. Neither of those happened. What happened was a job that ran, succeeded, and did nothing, for seven days, because it was waiting for a person who had not noticed.&lt;/p&gt;
&lt;h3 id=&quot;the-information-was-already-there&quot;&gt;The information was already there&lt;/h3&gt;
&lt;p&gt;The part I find annoying is that nothing was hidden. The reason is written every single morning, in the run log, in Italian, on stderr. The job knows exactly why it is idle and says so out loud.&lt;/p&gt;
&lt;p&gt;What it does not know is how long it has been saying it. &lt;code&gt;niente&lt;/code&gt; on the second of the month and &lt;code&gt;niente&lt;/code&gt; on the eighth are the same string, and the only thing that separates them is a week of my attention going somewhere else. No alarm reads that log line, and no counter turns it into a duration.&lt;/p&gt;
&lt;p&gt;Green means the machine is healthy. I had been reading it as &quot;the work is moving&quot;, and those are different claims.&lt;/p&gt;
&lt;h3 id=&quot;how-it-ended&quot;&gt;How it ended&lt;/h3&gt;
&lt;p&gt;On the eighth I opened Studio and tagged 11 of the 19 sources: two from the regulator as tier 1, nine analyst reports as tier 2 and independent. I left out a vendor case study, which fails the independence test by construction, and seven security bulletins whose scraped body is only a title.&lt;/p&gt;
&lt;p&gt;After that the pipeline did the rest in a few minutes. Generate wrote the piece from eight sources, embed rebuilt the chunks, export produced the two markdown files, and the judge on the pull request failed me for weak attribution, which is a story for another post.&lt;/p&gt;
&lt;p&gt;The September issue went out a week late. Nothing had broken, and every run had been green.&lt;/p&gt;
&lt;p&gt;So I do not need a fourth alarm. I need the daily job to count the mornings it has spent answering &lt;code&gt;niente&lt;/code&gt;, and to say that number where I actually look.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32698/a-week-of-green-runs-and-the-pipeline-was-waiting-for-me</guid>
<pubDate>Tue, 27 Oct 2026 16:02:11 +0000</pubDate>
</item>
<item>
<title>I built a Supabase-style query client that talks directly to Postgres</title>
<link>https://coderlegion.com/31116/i-built-a-supabase-style-query-client-that-talks-directly-to-postgres</link>
<description>&lt;p&gt;I like the Supabase JS client. &lt;code&gt;.from('todos').select('id, task').eq('done', false)&lt;/code&gt; reads like what I mean, and &lt;code&gt;{ data, error }&lt;/code&gt; means I'm not wrapping every query in try/catch.&lt;/p&gt;
&lt;p&gt;What I don't always want is the stuff around it: a project URL, an anon key, and a PostgREST service sitting between my Node server and a Postgres I already control.&lt;/p&gt;
&lt;p&gt;So I built &lt;strong&gt;pg-fluent&lt;/strong&gt;: the &lt;code&gt;postgrest-js&lt;/code&gt; query builder, executed directly against PostgreSQL through &lt;code&gt;node-postgres&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { createClient } from 'pg-fluent'

const db = createClient({ connectionString: process.env.DATABASE_URL })

const { data, error } = await db
  .from('todos')
  .select('id, task, done')
  .eq('done', false)
  .order('id')
  .limit(10)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you've used &lt;code&gt;supabase-js&lt;/code&gt;, that's already familiar. The difference is there's no HTTP request on the other end, just one parameterized SQL statement over a normal Postgres connection.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install pg-fluent
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/gerald-mathew/pg-fluent&quot;&gt;https://github.com/gerald-mathew/pg-fluent&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;who-it-s-for&quot;&gt;Who it's for&lt;/h3&gt;
&lt;p&gt;People running long-lived servers (Express, Fastify, a VPS, containers) who like the Supabase query API but want to connect straight to Postgres, or who are moving off Supabase's hosted stack and don't want to rewrite every query. It's &lt;strong&gt;server-side only&lt;/strong&gt;, more on that below.&lt;/p&gt;
&lt;h3 id=&quot;how-it-works&quot;&gt;How it works&lt;/h3&gt;
&lt;p&gt;I didn't reimplement the builder. pg-fluent uses the real &lt;code&gt;@supabase/postgrest-js&lt;/code&gt; and hands it a custom &lt;code&gt;fetch&lt;/code&gt;. When the builder &quot;sends&quot; a request, pg-fluent catches it, parses the URL, headers and body the way PostgREST would, plans one parameterized SQL statement, runs it through &lt;code&gt;pg&lt;/code&gt;, and returns a response &lt;code&gt;postgrest-js&lt;/code&gt; unpacks as if a server had answered.&lt;/p&gt;
&lt;p&gt;Results are aggregated to JSON inside Postgres, so you get the same shape as Supabase. Embeds like &lt;code&gt;select('id, title, author:users(name)')&lt;/code&gt; become subqueries that aggregate to JSON, with relationships resolved from the live schema (cached, with auto-refresh).&lt;/p&gt;
&lt;p&gt;Since I'm intercepting what the builder emits, an upstream change could quietly change the SQL. So the dependency is pinned to an exact version, and &lt;code&gt;docs/UPSTREAM.md&lt;/code&gt; records every bump with the date and reason. A bump that changes observable SQL or results is at least a minor release.&lt;/p&gt;
&lt;h3 id=&quot;what-you-get-beyond-the-basics&quot;&gt;What you get beyond the basics&lt;/h3&gt;
&lt;p&gt;The whole &lt;code&gt;postgrest-js&lt;/code&gt; surface works: filters, &lt;code&gt;or()&lt;/code&gt;/&lt;code&gt;not()&lt;/code&gt;/&lt;code&gt;filter()&lt;/code&gt;, JSON paths like &lt;code&gt;meta-&amp;gt;&amp;gt;author&lt;/code&gt;, &lt;code&gt;range()&lt;/code&gt;, &lt;code&gt;single()&lt;/code&gt;/&lt;code&gt;maybeSingle()&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;upsert&lt;/code&gt;, &lt;code&gt;rpc&lt;/code&gt;, &lt;code&gt;csv()&lt;/code&gt;, &lt;code&gt;explain()&lt;/code&gt;, and embeds (many-to-one, one-to-many, many-to-many, &lt;code&gt;!inner&lt;/code&gt;, aliases, spread). Then the things that only make sense with a direct connection:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;See the SQL without running it:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { text, values } = await db.from('todos').select('id').eq('done', false).toSQL()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Transactions with savepoints:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const user = await db.transaction(async (trx) =&amp;gt; {
  const { data, error } = await trx.from('users').insert({ name: 'G' }).select().single()
  if (error) throw error
  await trx.from('profiles').insert({ user_id: data.id })
  return data
}, { isolationLevel: 'read committed' })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Raw SQL, safe by default.&lt;/strong&gt; The tagged template always binds interpolations as parameters. The only way to run SQL text you built yourself is &lt;code&gt;db.unsafe()&lt;/code&gt;, and the name is deliberate.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { data } = await db.sql`select * from users where id = ${id}`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Streaming, COPY, LISTEN/NOTIFY:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;for await (const row of db.from('events').select('*').stream()) {
  await handle(row)
}

await db.copyFrom('events', rows, { columns: ['id', 'kind'], format: 'csv' })

const sub = await db.listen('orders', (payload) =&amp;gt; console.log(payload))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The listener gets its own non-pooled connection and reconnects with backoff.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Named connections and read replicas.&lt;/strong&gt; Reads can go to replicas, while writes, RPC, transactions and LISTEN always hit the primary. Add &lt;code&gt;.primary()&lt;/code&gt; when you need read-your-writes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Type generation:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pg-fluent gen-types --connection &quot;$DATABASE_URL&quot; --schema public --out ./database.types.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output is a &lt;code&gt;Database&lt;/code&gt; type compatible with &lt;code&gt;postgrest-js&lt;/code&gt;, so &lt;code&gt;createClient&amp;lt;Database&amp;gt;(...)&lt;/code&gt; just works.&lt;/p&gt;
&lt;p&gt;There are also &lt;code&gt;logger&lt;/code&gt; and &lt;code&gt;onQuery&lt;/code&gt; hooks and optional OpenTelemetry spans if &lt;code&gt;@opentelemetry/api&lt;/code&gt; is installed. Parameter values are never logged unless you opt in.&lt;/p&gt;
&lt;h3 id=&quot;guardrails-by-default&quot;&gt;Guardrails by default&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unfiltered &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; is refused&lt;/strong&gt; with &lt;code&gt;PGFLUENT_UNFILTERED_MUTATION&lt;/code&gt;. Opt in with &lt;code&gt;.allowUnfiltered()&lt;/code&gt; or a client option.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reads are capped:&lt;/strong&gt; &lt;code&gt;maxRows&lt;/code&gt; defaults to 1000 when you set no limit or range.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Statements time out:&lt;/strong&gt; &lt;code&gt;statementTimeoutMs&lt;/code&gt; defaults to 30 seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retries are read-only.&lt;/strong&gt; Transient connection failures retry with backoff for reads, and writes are never retried.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;how-i-tested-it&quot;&gt;How I tested it&lt;/h3&gt;
&lt;p&gt;I didn't want to say &quot;drop-in&quot; on a hunch, so:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Differential tests against a real PostgREST server.&lt;/strong&gt; The same requests go through both, and the payloads must match. One difference is intentional and documented: a reserved parameter name like &lt;code&gt;columns&lt;/code&gt; or &lt;code&gt;select&lt;/code&gt; used as a filter is rejected with &lt;code&gt;PGFLUENT_INVALID_QUERY&lt;/code&gt;. PostgREST silently ignores it, which turns a scoped read into a full-table read.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Property-based injection tests&lt;/strong&gt; with &lt;code&gt;fast-check&lt;/code&gt;. Hostile strings go in as filter values, the SQL text must match the baseline, the values must land in parameters only, and a canary table must survive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A real CI matrix:&lt;/strong&gt; Node 22 and 24 against Postgres 15, 16, 17 and 18.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's 200+ tests. There's also a benchmark harness against raw &lt;code&gt;pg&lt;/code&gt;, but I'm &lt;strong&gt;not&lt;/strong&gt; publishing performance claims. Network and pooling change the numbers too much, so it only reports methodology and variance for you to run yourself.&lt;/p&gt;
&lt;h3 id=&quot;the-rls-caveat-please-read-&quot;&gt;The RLS caveat (please read)&lt;/h3&gt;
&lt;p&gt;pg-fluent runs statements as &lt;strong&gt;the connection's own role&lt;/strong&gt;, like &lt;code&gt;psql&lt;/code&gt; or any other driver. It doesn't set &lt;code&gt;request.jwt.claims&lt;/code&gt; and doesn't emulate Supabase's &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt; roles.&lt;/p&gt;
&lt;p&gt;So &lt;strong&gt;RLS policies written for Supabase's roles don't automatically protect you here&lt;/strong&gt;. Connect as the table owner or a &lt;code&gt;BYPASSRLS&lt;/code&gt; role and RLS is skipped entirely. Use a least-privilege role, keep this on the server, and never let database credentials reach a browser. For per-user enforcement, connect as a non-owner role and set &lt;code&gt;SET ROLE&lt;/code&gt; and the JWT claims yourself. If you're migrating from &lt;code&gt;supabase-js&lt;/code&gt;, treat this as step zero.&lt;/p&gt;
&lt;h3 id=&quot;what-s-missing-or-rough&quot;&gt;What's missing or rough&lt;/h3&gt;
&lt;p&gt;It's &lt;code&gt;0.2.x&lt;/code&gt;, so, honestly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Embedding &lt;strong&gt;views&lt;/strong&gt; isn't supported yet (you can read them, just not embed them).&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;storage&lt;/code&gt; or &lt;code&gt;functions&lt;/code&gt;. It's a database client, not a BaaS SDK.&lt;/li&gt;
&lt;li&gt;LISTEN/NOTIFY doesn't work through transaction-mode poolers like PgBouncer. Use session-mode or a direct connection.&lt;/li&gt;
&lt;li&gt;Replica reads are asynchronous, and there's no lag measurement in v1.&lt;/li&gt;
&lt;li&gt;Requires Node 22+ and Postgres 15 to 18.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;try-it-break-it&quot;&gt;Try it, break it&lt;/h3&gt;
&lt;p&gt;If you have a Supabase-style codebase and a Postgres you control, point &lt;code&gt;pg-fluent&lt;/code&gt; at a test database and tell me what breaks. Issues and PRs are welcome at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/gerald-mathew/pg-fluent&quot;&gt;https://github.com/gerald-mathew/pg-fluent&lt;/a&gt;, and security reports go through the private channel in &lt;code&gt;SECURITY.md&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I especially want to know which &lt;code&gt;postgrest-js&lt;/code&gt; queries behave differently from what you expect. Real queries find things my fixtures won't.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31116/i-built-a-supabase-style-query-client-that-talks-directly-to-postgres</guid>
<pubDate>Wed, 14 Oct 2026 22:57:08 +0000</pubDate>
</item>
<item>
<title>Kafka Just Learned to Be a Queue. Postgres Has Been One All Along.</title>
<link>https://coderlegion.com/30477/kafka-just-learned-to-be-a-queue-postgres-has-been-one-all-along</link>
<description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/firfircelik/kafka-just-learned-to-be-a-queue-postgres-has-been-one-all-along-2i9g&quot;&gt;DEV.to&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;On February 17, 2026, Apache Kafka 4.2 shipped with this line in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/blog/2026/02/17/apache-kafka-4.2.0-release-announcement/&quot;&gt;release announcement&lt;/a&gt;: &quot;Kafka Queues (Share Groups) is now production-ready.&quot;&lt;/p&gt;
&lt;p&gt;That sentence quietly reopens one of backend engineering's oldest arguments. For years, &quot;should my job queue be Postgres or Kafka?&quot; had a lazy answer from both camps. The Kafka camp said Kafka scales. The Postgres camp said Kafka is not a queue: your consumer count is capped by your partition count, there is no per-message ack, and one poison message blocks everything behind it in its partition.&lt;/p&gt;
&lt;p&gt;The second argument just lost most of its teeth. So this post rebuilds the comparison from primary sources and two small demos you can run in a few minutes: a Postgres &lt;code&gt;SKIP LOCKED&lt;/code&gt; queue in Python, and a Kafka share group in Go.&lt;/p&gt;
&lt;h3 id=&quot;the-thesis&quot;&gt;The thesis&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;If a job is born inside a database transaction, Postgres is still the better queue in 2026.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Kafka share groups are real, well designed, and production-ready on the broker. But they answer a different question: &quot;I already run Kafka, and now I also need queue semantics.&quot; They do not answer &quot;I need a queue.&quot; Those two questions have different winners, and a team that does not already run Kafka would be adopting a whole platform to get one feature.&lt;/p&gt;
&lt;h3 id=&quot;the-antithesis-at-full-strength&quot;&gt;The antithesis, at full strength&lt;/h3&gt;
&lt;p&gt;Here is the best case against that thesis. It is a good one.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cwiki.apache.org/confluence/display/KAFKA/KIP-932%3A+Queues+for+Kafka&quot;&gt;KIP-932&lt;/a&gt; adds a new kind of group, the share group, and with it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Consumers beyond partition count.&lt;/strong&gt; &quot;The number of consumers in a share group can exceed the number of partitions in a topic.&quot; The old trick of over-partitioning just to get parallelism goes away.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-record acknowledgement.&lt;/strong&gt; A consumer can accept, release (retry), or reject (unprocessable) each record. Kafka 4.2 added a fourth option, RENEW, to extend the lock for slow work (&lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/blog/2026/02/17/apache-kafka-4.2.0-release-announcement/&quot;&gt;KIP-1222&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delivery counting with a poison-message limit.&lt;/strong&gt; After the configured number of attempts (default 5, per the &lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/43/configuration/broker-configs/&quot;&gt;4.3 broker configs&lt;/a&gt;), a record is archived instead of redelivered forever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No queue depth limit and replay.&lt;/strong&gt; In the KIP's own words: &quot;queues done in a Kafka way, with no maximum queue depth and the ability to reset to a specific time for point-in-time recovery.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Many readers, one log.&lt;/strong&gt; Several share groups can consume the same topic independently, next to ordinary consumer groups.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the Postgres side has a well-known way to rot: MVCC. In the demo below, a single idle session holding an old snapshot made the median claim latency go from &lt;strong&gt;about 0.27 ms to about 15 ms&lt;/strong&gt; on the same queue. That is not a theoretical concern. It is one forgotten &lt;code&gt;BEGIN&lt;/code&gt; away.&lt;/p&gt;
&lt;p&gt;So the antithesis is: Kafka now does queues properly, scales past one box, keeps history, and does not degrade because someone left a psql session open. The answer to that lives in the failure modes on both sides.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-shipped-facts-as-of-october-5-2026-&quot;&gt;What actually shipped (facts, as of October 5, 2026)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Kafka share groups (KIP-932) &lt;/th&gt;&lt;th&gt;Postgres &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; queue&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Unit of work &lt;/td&gt;&lt;td&gt;Record in a topic partition &lt;/td&gt;&lt;td&gt;Row in a table&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Claim &lt;/td&gt;&lt;td&gt;Broker-side acquisition lock, default 30 s (&lt;code&gt;share.record.lock.duration.ms&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;Row lock held by your transaction&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ack &lt;/td&gt;&lt;td&gt;ACCEPT / RELEASE / REJECT / RENEW &lt;/td&gt;&lt;td&gt;&lt;code&gt;COMMIT&lt;/code&gt; (and delete or update the row)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Crash behavior &lt;/td&gt;&lt;td&gt;Lock expires (or the share session closes), record becomes available again &lt;/td&gt;&lt;td&gt;Transaction aborts, lock is released, row is visible again&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Delivery guarantee &lt;/td&gt;&lt;td&gt;At-least-once (&lt;a rel=&quot;nofollow&quot; href=&quot;https://cwiki.apache.org/confluence/display/KAFKA/KIP-932%3A+Queues+for+Kafka&quot;&gt;KIP-932&lt;/a&gt;) &lt;/td&gt;&lt;td&gt;At-least-once for external side effects; atomic with any writes in the same database&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ordering &lt;/td&gt;&lt;td&gt;Not guaranteed across batches, especially on redelivery &lt;/td&gt;&lt;td&gt;Whatever your &lt;code&gt;ORDER BY&lt;/code&gt; gives you, best effort under concurrency&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Poison messages &lt;/td&gt;&lt;td&gt;Delivery count limit, default 5 &lt;/td&gt;&lt;td&gt;You write it (an &lt;code&gt;attempts&lt;/code&gt; column)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dead-letter queue &lt;/td&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cwiki.apache.org/confluence/spaces/KAFKA/pages/429064575/Release+Plan+4.4.0&quot;&gt;KIP-1191&lt;/a&gt; listed as complete for 4.4.0, which is not released yet &lt;/td&gt;&lt;td&gt;You write it (a table)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Client support matters more than broker support, because your code talks to a client:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Java:&lt;/strong&gt; &lt;code&gt;KafkaShareConsumer&lt;/code&gt; is the reference client (&lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/42/javadoc/org/apache/kafka/clients/consumer/KafkaShareConsumer.html&quot;&gt;javadoc&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Go:&lt;/strong&gt; franz-go added &quot;full support for KIP-932 share groups&quot; in v1.21.0 (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/twmb/franz-go/blob/master/CHANGELOG.md&quot;&gt;changelog&lt;/a&gt;). The demo below uses v1.22.1.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Python and C:&lt;/strong&gt; librdkafka and confluent-kafka-python 2.15.0 (June 30, 2026) ship the share consumer as &lt;strong&gt;Preview&lt;/strong&gt;, with an explicit note that it &quot;should not be used in production environments&quot; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/confluentinc/confluent-kafka-python/releases/tag/v2.15.0&quot;&gt;confluent-kafka-python v2.15.0&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/confluentinc/librdkafka/releases/tag/v2.15.0&quot;&gt;librdkafka v2.15.0&lt;/a&gt;). Confluent's own GA post says non-Java client support is &quot;targeted for the second half of 2026&quot; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.confluent.io/blog/kafka-queue-semantics-share-consumer-ga/&quot;&gt;Confluent blog&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your workers are Python on the mainstream Confluent client, Kafka queues are explicitly not for production today. That alone settles the question for a lot of data teams.&lt;/p&gt;
&lt;h3 id=&quot;the-postgres-queue-is-one-sql-statement&quot;&gt;The Postgres queue is one SQL statement&lt;/h3&gt;
&lt;p&gt;Here is the entire claim step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH next AS (
    SELECT id FROM jobs
    ORDER BY id
    LIMIT 50
    FOR UPDATE SKIP LOCKED
)
DELETE FROM jobs j USING next
WHERE j.id = next.id
RETURNING j.id, j.payload;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it inside a transaction, do the work, write the result, &lt;code&gt;COMMIT&lt;/code&gt;. The commit is the ack. If the worker dies before committing, Postgres rolls the transaction back, the rows were never deleted, and another worker picks them up. That is the same contract as a share group's acquisition lock, except the &quot;lock timeout&quot; is &quot;your connection died.&quot;&lt;/p&gt;
&lt;p&gt;The Postgres docs are blunt about what &lt;code&gt;SKIP LOCKED&lt;/code&gt; is for: &quot;Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table&quot; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/sql-select.html&quot;&gt;SELECT docs&lt;/a&gt;).&lt;/p&gt;
&lt;h4 id=&quot;demo-a-does-skip-locked-actually-matter-&quot;&gt;Demo A: does SKIP LOCKED actually matter?&lt;/h4&gt;
&lt;p&gt;Eight worker threads drain 20,000 jobs in batches of 50. Each worker writes a row into a &lt;code&gt;done&lt;/code&gt; table in the same transaction as the dequeue. The script asserts zero duplicates and zero lost jobs after every run. Each script execution does three alternating runs per cell and reports the median; the table shows the range of those medians across four executions:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Simulated work per batch (lock held) &lt;/th&gt;&lt;th&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; &lt;/th&gt;&lt;th&gt;plain &lt;code&gt;FOR UPDATE&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;0 ms &lt;/td&gt;&lt;td&gt;14,400 to 17,600 jobs/s &lt;/td&gt;&lt;td&gt;13,200 to 32,800 jobs/s (about 32,000 in 3 of 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5 ms &lt;/td&gt;&lt;td&gt;14,000 to 17,600 jobs/s &lt;/td&gt;&lt;td&gt;7,100 to 7,200 jobs/s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Duplicates: 0. Lost: 0. In every run, for both variants.&lt;/p&gt;
&lt;p&gt;The first row is the surprise: with near-zero lock hold time, plain &lt;code&gt;FOR UPDATE&lt;/code&gt; was usually about twice as fast. A plausible explanation (interpretation, not measured): each &lt;code&gt;SKIP LOCKED&lt;/code&gt; claim steps over the rows the other seven workers hold, while a blocked worker waits very briefly and gets its rows. The second row is the one that matters in production. Once workers hold the claim while doing real work, the blocking version collapses to about 7,000 jobs/s because everyone queues behind the head of the table, and &lt;code&gt;SKIP LOCKED&lt;/code&gt; comes out 2 to 2.5 times ahead. Both variants lost nothing. &lt;code&gt;SKIP LOCKED&lt;/code&gt; is about throughput under real work, not correctness.&lt;/p&gt;
&lt;p&gt;One subtle thing the script surfaced: with &lt;code&gt;SKIP LOCKED&lt;/code&gt;, a worker can get zero rows while jobs still exist, because everything left is claimed by someone else. It happened between 2 and 36 times per run. Treat an empty claim as &quot;back off and poll again,&quot; never as &quot;the queue is empty, exit.&quot;&lt;/p&gt;
&lt;h4 id=&quot;demo-b-kill-a-worker-mid-batch&quot;&gt;Demo B: kill a worker mid-batch&lt;/h4&gt;
&lt;p&gt;A session claims 10 of 100 jobs and never commits. While it holds them, other sessions can claim 90. After &lt;code&gt;pg_terminate_backend()&lt;/code&gt; on that session (the database-side equivalent of &lt;code&gt;kill -9&lt;/code&gt; on the worker), all 100 are claimable again. No lease column, no reaper job, as long as jobs are short (more on that below).&lt;/p&gt;
&lt;h4 id=&quot;the-real-advantage-the-job-and-the-data-commit-together&quot;&gt;The real advantage: the job and the data commit together&lt;/h4&gt;
&lt;p&gt;This is the part Kafka cannot match without extra machinery. If your API inserts a user and enqueues &quot;send welcome email&quot; in the same transaction, the job exists if and only if the user exists. &lt;a rel=&quot;nofollow&quot; href=&quot;https://riverqueue.com/&quot;&gt;River&lt;/a&gt;, a Go job queue backed by Postgres, makes this its core argument (&lt;a rel=&quot;nofollow&quot; href=&quot;https://riverqueue.com/docs/transactional-enqueueing&quot;&gt;River docs&lt;/a&gt;): with a separate queue store you either enqueue after commit and lose jobs on a crash, or enqueue before commit and have workers race a row they cannot see yet.&lt;/p&gt;
&lt;p&gt;The mainstream already voted on this. Solid Queue &quot;is configured by default in new Rails 8 applications&quot; and &quot;leverages the &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; clause&quot; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/rails/solid_queue&quot;&gt;Solid Queue README&lt;/a&gt;).&lt;/p&gt;
&lt;h3 id=&quot;where-the-postgres-queue-dies-one-old-snapshot&quot;&gt;Where the Postgres queue dies: one old snapshot&lt;/h3&gt;
&lt;p&gt;A queue table is the worst-case MVCC workload: every row is inserted once and deleted once. Postgres does not remove a deleted row immediately; the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/routine-vacuuming.html&quot;&gt;routine vacuuming docs&lt;/a&gt; explain that &quot;the row version must not be deleted while it is still potentially visible to other transactions.&quot; If anything holds an old snapshot, vacuum cannot clean, and the dead rows pile up right where your claim query starts scanning.&lt;/p&gt;
&lt;h4 id=&quot;demo-c-churn-200-000-jobs-then-measure&quot;&gt;Demo C: churn 200,000 jobs, then measure&lt;/h4&gt;
&lt;p&gt;The script pushes 200,000 jobs through the queue, enqueues 5,000 fresh ones, runs &lt;code&gt;VACUUM (VERBOSE)&lt;/code&gt;, and times 200 claims of 10 jobs each. Then it repeats the whole thing with one extra session sitting &lt;code&gt;idle in transaction&lt;/code&gt; in &lt;code&gt;REPEATABLE READ&lt;/code&gt;, which is exactly what a forgotten report query or a paused migration looks like.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Median claim latency &lt;/th&gt;&lt;th&gt;&lt;code&gt;VACUUM VERBOSE&lt;/code&gt; says&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;No old snapshot &lt;/td&gt;&lt;td&gt;0.269 ms / 0.272 ms &lt;/td&gt;&lt;td&gt;40,293 / 37,680 removed, 0 dead but not yet removable&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;One idle &lt;code&gt;REPEATABLE READ&lt;/code&gt; session &lt;/td&gt;&lt;td&gt;15.754 ms / 14.961 ms &lt;/td&gt;&lt;td&gt;0 removed, 200,000 dead but not yet removable&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;(Two executions of the script, shown as first / second. In the no-snapshot case autovacuum had already cleaned most of the dead rows before the manual &lt;code&gt;VACUUM&lt;/code&gt; ran.)&lt;/p&gt;
&lt;p&gt;Same table, same query, same hardware. Roughly 55 to 58 times slower per claim, and the dead rows keep accumulating with every job processed while that session lives. Nothing errors. Your queue just gets slower until someone notices.&lt;/p&gt;
&lt;p&gt;Things that hold old snapshots in real systems, all documented:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Long-running or idle-in-transaction sessions (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/routine-vacuuming.html&quot;&gt;vacuum docs&lt;/a&gt; tell you to find them via &lt;code&gt;pg_stat_activity&lt;/code&gt; and &lt;code&gt;backend_xmin&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Old replication slots (same page).&lt;/li&gt;
&lt;li&gt;A hot standby with &lt;code&gt;hot_standby_feedback&lt;/code&gt; on, which &quot;can cause database bloat on the primary for some workloads&quot; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/runtime-config-replication.html&quot;&gt;replication config docs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Mitigations that actually work:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Set &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; for application and human roles, and &lt;code&gt;transaction_timeout&lt;/code&gt; (added in &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/17/release-17.html&quot;&gt;PostgreSQL 17&lt;/a&gt;) where it is safe. Both are documented in &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/runtime-config-client.html&quot;&gt;client connection defaults&lt;/a&gt;, including the warning to be careful with connection poolers.&lt;/li&gt;
&lt;li&gt;Alert on &quot;dead but not yet removable&quot; and on the age of the oldest &lt;code&gt;backend_xmin&lt;/code&gt;, not just on queue depth.&lt;/li&gt;
&lt;li&gt;Keep analytics off the primary. An extra test on the same box: an old snapshot held in a &lt;em&gt;different database&lt;/em&gt; on the same cluster did not block cleanup of the queue table (50,000 removed), while the same snapshot in the &lt;em&gt;same database&lt;/em&gt; blocked all of it (50,000 dead but not yet removable). That is a real escape hatch, but it has a price: a queue in its own database can no longer commit atomically with your application tables, which was the main reason to use Postgres in the first place.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;the-trap-your-worker-can-be-the-long-transaction&quot;&gt;The trap: your worker can be the long transaction&lt;/h4&gt;
&lt;p&gt;The claim pattern above holds the transaction open while the job runs. For a 50 ms job that is fine. For a 5 minute job, the worker itself is the old transaction. A quick check on the same box: a worker sitting &lt;code&gt;idle in transaction&lt;/code&gt; after its &lt;code&gt;DELETE ... RETURNING&lt;/code&gt; (plain &lt;code&gt;READ COMMITTED&lt;/code&gt;, nothing exotic) left 50,000 freshly deleted rows in another table of the same database &quot;dead but not yet removable.&quot; So long jobs need a lease instead: claim and commit in a short transaction, work outside any transaction, then ack only if you still own the lease.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- claim: short transaction, commit right away
UPDATE jobs
SET locked_until = now() + interval '5 minutes',
    lease_token  = gen_random_uuid(),
    attempts     = attempts + 1
WHERE id IN (
    SELECT id FROM jobs
    WHERE locked_until IS NULL OR locked_until &amp;lt; now()
    ORDER BY id
    LIMIT 10
    FOR UPDATE SKIP LOCKED
)
RETURNING id, payload, lease_token, attempts;

-- ack: only the current lease holder can delete the job
DELETE FROM jobs WHERE id = $1 AND lease_token = $2;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tested on the box: when a lease expired and a second worker re-claimed the job, the first worker's late ack deleted 0 rows and the second worker's ack deleted 1. Notice what you just built: an acquisition lock with a timeout and a delivery count. That is a share group, in SQL. You also gave up the &quot;job commits with the data&quot; property for the work itself, so this path needs idempotent jobs, exactly like Kafka.&lt;/p&gt;
&lt;h4 id=&quot;a-note-on-listen-notify&quot;&gt;A note on LISTEN/NOTIFY&lt;/h4&gt;
&lt;p&gt;If you use &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; to wake workers, know the documented edges (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/sql-notify.html&quot;&gt;NOTIFY docs&lt;/a&gt;): notifications are delivered only when the sending transaction commits, payloads must be shorter than 8000 bytes by default, and the notification queue (8 GB in a standard install) cannot be cleaned while a listening session sits in a long transaction. If that queue fills, &quot;transactions calling NOTIFY will fail at commit.&quot; Use NOTIFY as a doorbell, never as the queue itself. PostgreSQL 19, with GA scheduled for October 29, 2026 (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/message-id/915517c4-d0ee-49f9-bbeb-c683ef2b9d3f%40postgresql.org&quot;&gt;release dates&lt;/a&gt;), lists an improvement to &quot;only wake up backends that are listening to specified notifications&quot; in its &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/19/release-19.html&quot;&gt;draft release notes&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;where-kafka-share-groups-bite&quot;&gt;Where Kafka share groups bite&lt;/h3&gt;
&lt;p&gt;These are less about slow decay and more about semantics you have to design around.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. The lock timeout is a duplicate-work timer.&lt;/strong&gt; The default acquisition lock is 30 seconds. If your job takes 45, the broker hands the record to another consumer while the first one is still working. Use RENEW for long jobs, and make processing idempotent anyway. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://pkg.go.dev/github.com/twmb/franz-go/pkg/kgo&quot;&gt;franz-go &lt;code&gt;kgo&lt;/code&gt; docs&lt;/a&gt; put it plainly: &quot;Share group consumers are at-least-once regardless: any TCP hiccup can cause re-delivery, so user processing must be idempotent.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Ordering is gone.&lt;/strong&gt; KIP-932: &quot;The records in a share-partition can be delivered out of order to a consumer, in particular when redeliveries occur.&quot; If you need per-key ordering, you want a classic consumer group, not a share group.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. A new share group starts at the end.&lt;/strong&gt; &lt;code&gt;share.auto.offset.reset&lt;/code&gt; defaults to &lt;code&gt;latest&lt;/code&gt; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/43/configuration/group-configs/&quot;&gt;group configs&lt;/a&gt;). Create a share group on a topic that already has a backlog and, unless you set &lt;code&gt;earliest&lt;/code&gt;, your workers will skip all of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Size-based retention can delete work you have not done.&lt;/strong&gt; From the KIP: retention based on size &quot;does potentially silently remove records that were eligible for delivery.&quot; A Postgres table does not delete your unprocessed jobs because the disk got full.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. The unit of sharing is the record batch.&lt;/strong&gt; The KIP says it directly: &quot;the natural unit of sharing is the record batch.&quot; In the Go demo below, four consumers share one partition. With default fetch settings, one consumer got 949 of 1,000 jobs and two got nothing. With &lt;code&gt;ShareMaxRecordsStrict()&lt;/code&gt; (the client side of the strict mode added by KIP-1206 in 4.2), the split was 246 / 251 / 252 / 251. Caveat: this ran against kfake, franz-go's in-process fake broker, not a real cluster, so treat the exact split as illustrative. The behavior matches what the KIP describes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. No atomicity with your database.&lt;/strong&gt; Writing a row to Postgres and producing to Kafka are two systems. The standard fix is the transactional outbox: write the event to an outbox table in the same transaction, then let CDC (for example the &lt;a rel=&quot;nofollow&quot; href=&quot;https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html&quot;&gt;Debezium outbox event router&lt;/a&gt;) publish it. Notice what that means: the reliable way to feed a Kafka queue from an OLTP app starts with a Postgres table.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;7. In-flight work is capped per partition.&lt;/strong&gt; &lt;code&gt;group.share.partition.max.record.locks&lt;/code&gt; defaults to 2000 per share-partition (&lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/43/configuration/broker-configs/&quot;&gt;4.3 broker configs&lt;/a&gt;). Slow consumers holding locks can stall fetching on that partition until locks are acked or expire. And the delivery count &quot;cannot be relied upon to be precise in all situations&quot; (KIP-932), so do not build billing logic on it.&lt;/p&gt;
&lt;h4 id=&quot;the-go-side-a-share-group-on-one-partition&quot;&gt;The Go side: a share group on one partition&lt;/h4&gt;
&lt;p&gt;The core loop, using franz-go v1.22.1:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;cl, err := kgo.NewClient(
    kgo.SeedBrokers(addrs...),
    kgo.ConsumeTopics(&quot;jobs&quot;),
    kgo.ShareGroup(&quot;email-workers&quot;),
    kgo.ShareMaxRecords(25),
    kgo.ShareMaxRecordsStrict(), // without this one member may get a whole record batch
)
if err != nil {
    panic(err)
}
defer cl.Close()

for {
    pollCtx, cancel := context.WithTimeout(ctx, time.Second)
    fetches := cl.PollFetches(pollCtx)
    cancel()
    fetches.EachRecord(func(r *kgo.Record) {
        if err := sendEmail(r.Value); err != nil {
            r.Ack(kgo.AckRelease) // transient: try again, delivery count goes up
            return
        }
        r.Ack(kgo.AckAccept)
    })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the full demo, every job whose number ends in 0 is released on its first delivery to simulate a transient failure. Three runs in a row: 1,000 of 1,000 jobs accepted, 100 on a second delivery, 0 accepted twice, four consumers on one partition. The headline feature works.&lt;/p&gt;
&lt;h3 id=&quot;the-decision-rule&quot;&gt;The decision rule&lt;/h3&gt;
&lt;p&gt;Put it on one line first:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tasks that belong to a transaction go in the database that owns the transaction. Facts that many systems read go in a log. Share groups let the log also hand out tasks, which is great once you already have the log.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Then the checklist.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose a Postgres queue when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The job is created in the same transaction as the business data it depends on.&lt;/li&gt;
&lt;li&gt;Your workers are in Python, or any language without a production share consumer yet.&lt;/li&gt;
&lt;li&gt;Your measured peak fits your Postgres with your payloads. Demo A did 14,000 to 17,600 tiny jobs/s with &lt;code&gt;SKIP LOCKED&lt;/code&gt; on 8 vCPUs; River claims &quot;tens of thousands of jobs per second.&quot; Measure, do not inherit someone's number.&lt;/li&gt;
&lt;li&gt;Jobs are short enough to run inside the claim transaction, or you use leases for the long ones.&lt;/li&gt;
&lt;li&gt;You can enforce &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;, keep analytics off the primary, and alert on dead-but-not-removable rows.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Choose Kafka share groups when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Kafka is already the system of record for these events, and the queue is one more reader.&lt;/li&gt;
&lt;li&gt;Several independent consumers need the same stream, or you need replay and reset-to-timestamp.&lt;/li&gt;
&lt;li&gt;Your workers are on Java or Go (franz-go), processing is idempotent, and ordering does not matter.&lt;/li&gt;
&lt;li&gt;You are fine designing around lock timeouts, retention, and &lt;code&gt;share.auto.offset.reset&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Use both when&lt;/strong&gt; the job starts in your database but the work fans out to other teams: outbox table in Postgres, CDC into Kafka, share groups downstream.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flags that you picked wrong:&lt;/strong&gt; you are adding an outbox just to feed a Kafka queue that only one service reads (you wanted Postgres), or your Postgres queue needs a second database to stay fast (you are halfway to wanting a log).&lt;/p&gt;
&lt;h3 id=&quot;run-it-yourself&quot;&gt;Run it yourself&lt;/h3&gt;
&lt;p&gt;Hardware and versions for every number above: an 8 vCPU, 15 GB RAM Linux VM (kernel 6.12, Debian 13), PostgreSQL 17.11 with default settings except &lt;code&gt;max_connections=200&lt;/code&gt;, client and server on the same machine over a Unix socket, Python 3.13.5 with psycopg 3.3.6, Go 1.26.0 with franz-go v1.22.1 and kfake. These are toy payloads on one box. They show mechanisms and ratios, not capacity for your system.&lt;/p&gt;
&lt;p&gt;{% details pg_queue_demo.py (Python, needs a throwaway Postgres) %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;&quot;&quot;&quot;
Postgres-as-a-queue demo: SKIP LOCKED vs blocking FOR UPDATE, crash recovery, and the MVCC failure mode.
Needs a THROWAWAY Postgres (it drops tables named jobs and done).
  pip install &quot;psycopg[binary]&quot;
  DSN=&quot;host=/tmp port=54329 user=postgres dbname=postgres&quot; python pg_queue_demo.py
&quot;&quot;&quot;
import os, time, threading, statistics
import psycopg

DSN = os.environ.get(&quot;DSN&quot;, &quot;host=/tmp port=54329 user=postgres dbname=postgres&quot;)

CLAIM_SKIP = &quot;&quot;&quot;
WITH next AS (
    SELECT id FROM jobs
    ORDER BY id
    LIMIT %(n)s
    FOR UPDATE SKIP LOCKED
)
DELETE FROM jobs j USING next
WHERE j.id = next.id
RETURNING j.id, j.payload
&quot;&quot;&quot;
CLAIM_BLOCKING = CLAIM_SKIP.replace(&quot;FOR UPDATE SKIP LOCKED&quot;, &quot;FOR UPDATE&quot;)


def setup(conn):
    conn.execute(&quot;DROP TABLE IF EXISTS jobs, done&quot;)
    conn.execute(&quot;CREATE TABLE jobs (id bigserial PRIMARY KEY, payload jsonb NOT NULL)&quot;)
    # 'done' stands in for a side effect that lives in the SAME database as the queue
    conn.execute(&quot;CREATE TABLE done (job_id bigint NOT NULL, worker int NOT NULL)&quot;)
    conn.commit()


def enqueue(conn, n):
    conn.execute(&quot;INSERT INTO jobs (payload) SELECT jsonb_build_object('n', g) &quot;
                 &quot;FROM generate_series(1, %s) g&quot;, (n,))
    conn.commit()


def worker(wid, claim_sql, batch, stats, empties, work_ms):
    with psycopg.connect(DSN) as conn:
        while True:
            with conn.transaction():
                rows = conn.execute(claim_sql, {&quot;n&quot;: batch}).fetchall()
                if rows:
                    if work_ms:
                        time.sleep(work_ms / 1000)  # &quot;work&quot; done while holding the claim
                    with conn.cursor() as cur:      # result commits atomically with the dequeue
                        cur.executemany(&quot;INSERT INTO done (job_id, worker) VALUES (%s, %s)&quot;,
                                        [(r[0], wid) for r in rows])
            if rows:
                stats[wid] += len(rows)
                continue
            # An empty claim does not always mean an empty queue (see the blocking variant).
            left = conn.execute(&quot;SELECT count(*) FROM jobs&quot;).fetchone()[0]
            conn.commit()
            if left == 0:
                return
            empties[wid] += 1


def run_workers(claim_sql, workers, batch, work_ms=0.0):
    stats, empties = [0] * workers, [0] * workers
    ts = [threading.Thread(target=worker, args=(i, claim_sql, batch, stats, empties, work_ms))
          for i in range(workers)]
    t0 = time.perf_counter()
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    return time.perf_counter() - t0, sum(empties)


def experiment_throughput(n=20_000, workers=8, batch=50, repeats=3):
    for work_ms in (0.0, 5.0):
        print(f&quot;\n== A. {workers} workers drain {n} jobs, batch={batch}, &quot;
              f&quot;simulated work per batch={work_ms} ms, {repeats} alternating runs&quot;)
        results = {&quot;FOR UPDATE SKIP LOCKED&quot;: [], &quot;FOR UPDATE (blocking)&quot;: []}
        for _ in range(repeats):
            for label, sql in ((&quot;FOR UPDATE SKIP LOCKED&quot;, CLAIM_SKIP),
                               (&quot;FOR UPDATE (blocking)&quot;, CLAIM_BLOCKING)):
                with psycopg.connect(DSN) as conn:
                    setup(conn)
                    enqueue(conn, n)
                secs, empties = run_workers(sql, workers, batch, work_ms)
                with psycopg.connect(DSN) as conn:
                    total, distinct = conn.execute(
                        &quot;SELECT count(*), count(DISTINCT job_id) FROM done&quot;).fetchone()
                    left = conn.execute(&quot;SELECT count(*) FROM jobs&quot;).fetchone()[0]
                assert total == distinct == n and left == 0, (total, distinct, left)
                results[label].append((n / secs, empties))
        for label, rs in results.items():
            print(f&quot;{label:24s} median {statistics.median(r[0] for r in rs):7.0f} jobs/s | &quot;
                  f&quot;empty claims while work remained, per run: {[r[1] for r in rs]} | &quot;
                  f&quot;duplicates 0, lost 0&quot;)


def experiment_crash(n=100):
    print(&quot;\n== B. a worker dies mid-transaction&quot;)
    with psycopg.connect(DSN) as conn:
        setup(conn)
        enqueue(conn, n)
    victim = psycopg.connect(DSN)
    victim.execute(CLAIM_SKIP, {&quot;n&quot;: 10}).fetchall()  # claimed 10 jobs, never commits
    with psycopg.connect(DSN, autocommit=True) as admin:
        q = &quot;SELECT count(*) FROM (SELECT id FROM jobs FOR UPDATE SKIP LOCKED) s&quot;
        print(f&quot;victim holds its batch: claimable by others = {admin.execute(q).fetchone()[0]}&quot;)
        admin.execute(&quot;SELECT pg_terminate_backend(%s)&quot;, (victim.info.backend_pid,))
        time.sleep(0.5)
        print(f&quot;victim session killed:  claimable by others = {admin.execute(q).fetchone()[0]}&quot;)
    try:
        victim.close()
    except Exception:
        pass


def median_claim_ms(rounds=200):
    lat = []
    with psycopg.connect(DSN) as conn:
        for _ in range(rounds):
            t0 = time.perf_counter()
            with conn.transaction():
                conn.execute(CLAIM_SKIP, {&quot;n&quot;: 10}).fetchall()
            lat.append((time.perf_counter() - t0) * 1000)
    return statistics.median(lat)


def experiment_mvcc(churn=200_000):
    print(f&quot;\n== C. churn {churn} jobs through the queue, then time claims, &quot;
          f&quot;with and without one old snapshot held open&quot;)
    for hold in (False, True):
        with psycopg.connect(DSN) as conn:
            setup(conn)
        holder = None
        if hold:
            # stands in for a long report query, a stuck migration, or an idle-in-transaction session
            holder = psycopg.connect(DSN, autocommit=True)
            holder.execute(&quot;BEGIN ISOLATION LEVEL REPEATABLE READ&quot;)
            holder.execute(&quot;SELECT 1&quot;).fetchone()  # first statement takes the snapshot
            with psycopg.connect(DSN, autocommit=True) as c2:
                st, xmin = c2.execute(&quot;SELECT state, backend_xmin FROM pg_stat_activity &quot;
                                      &quot;WHERE pid = %s&quot;, (holder.info.backend_pid,)).fetchone()
            print(f&quot;   holder session: state={st!r}, backend_xmin={xmin}&quot;)
        with psycopg.connect(DSN) as conn:
            enqueue(conn, churn)
        run_workers(CLAIM_SKIP, 8, 100)            # drain everything, leaving dead rows behind
        with psycopg.connect(DSN) as conn:
            enqueue(conn, 5_000)                   # fresh work arrives behind the dead rows
        notes = []
        with psycopg.connect(DSN, autocommit=True) as conn:
            conn.add_notice_handler(lambda d: notes.append(d.message_primary or &quot;&quot;))
            conn.execute(&quot;VACUUM (VERBOSE) jobs&quot;)  # give vacuum every chance
        tuples_line = next((l.strip() for n in notes for l in n.splitlines() if &quot;tuples:&quot; in l), &quot;&quot;)
        med = median_claim_ms()
        with psycopg.connect(DSN) as conn:
            size = conn.execute(&quot;SELECT pg_size_pretty(pg_total_relation_size('jobs'))&quot;).fetchone()[0]
        print(f&quot;old snapshot held={str(hold):5s} median claim latency={med:8.3f} ms  &quot;
              f&quot;jobs table+index={size}\n   VACUUM VERBOSE: {tuples_line}&quot;)
        if holder:
            holder.execute(&quot;ROLLBACK&quot;)
            holder.close()


if __name__ == &quot;__main__&quot;:
    with psycopg.connect(DSN) as c:
        print(c.execute(&quot;SELECT version()&quot;).fetchone()[0])
    experiment_throughput()
    experiment_crash()
    experiment_mvcc()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% enddetails %}&lt;/p&gt;
&lt;p&gt;{% details main.go (Go, franz-go share group against kfake) %}&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// Share groups (KIP-932) in Go with franz-go: 4 consumers on ONE partition,
// per-record ack, and a transient failure that gets redelivered.
// Runs against kfake, franz-go's in-process fake broker, so no real cluster is needed.
package main

import (
&quot;context&quot;
&quot;fmt&quot;
&quot;strconv&quot;
&quot;sync&quot;
&quot;time&quot;

&quot;github.com/twmb/franz-go/pkg/kfake&quot;
&quot;github.com/twmb/franz-go/pkg/kgo&quot;
)

const (
topic   = &quot;jobs&quot;
group   = &quot;email-workers&quot;
total   = 1000
members = 4
)

func main() {
c, err := kfake.NewCluster(kfake.NumBrokers(1), kfake.SeedTopics(1, topic)) // ONE partition
if err != nil {
panic(err)
}
defer c.Close()
// Default is &quot;latest&quot;: a brand-new share group would skip everything already in the topic.
c.SetGroupConfigs(group, map[string]string{&quot;share.auto.offset.reset&quot;: &quot;earliest&quot;})

producer, err := kgo.NewClient(kgo.SeedBrokers(c.ListenAddrs()...), kgo.DefaultProduceTopic(topic))
if err != nil {
panic(err)
}
for i := 0; i &amp;lt; total; i++ {
producer.Produce(context.Background(), kgo.StringRecord(strconv.Itoa(i)), nil)
}
if err := producer.Flush(context.Background()); err != nil {
panic(err)
}
producer.Close()

var (
mu          sync.Mutex
accepted    = map[string]int{} // job -&amp;gt; times accepted
perMember   = make([]int, members)
redelivered int
)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

var wg sync.WaitGroup
for m := 0; m &amp;lt; members; m++ {
wg.Add(1)
go func(m int) {
defer wg.Done()
cl, err := kgo.NewClient(
kgo.SeedBrokers(c.ListenAddrs()...),
kgo.ConsumeTopics(topic),
kgo.ShareGroup(group),
kgo.ShareMaxRecords(25),
kgo.ShareMaxRecordsStrict(), // without this the broker may hand one member a whole record batch
)
if err != nil {
panic(err)
}
defer cl.Close()
for {
mu.Lock()
done := len(accepted) == total
mu.Unlock()
if done || ctx.Err() != nil {
_ = cl.FlushAcks(context.Background())
return
}
pollCtx, pollCancel := context.WithTimeout(ctx, time.Second)
fetches := cl.PollFetches(pollCtx)
pollCancel()
fetches.EachRecord(func(r *kgo.Record) {
n, _ := strconv.Atoi(string(r.Value))
if n%10 == 0 &amp;amp;&amp;amp; r.DeliveryCount() == 1 {
r.Ack(kgo.AckRelease) // transient failure: give it back for another attempt
return
}
time.Sleep(2 * time.Millisecond) // pretend to send the email
r.Ack(kgo.AckAccept)
mu.Lock()
accepted[string(r.Value)]++
perMember[m]++
if r.DeliveryCount() &amp;gt; 1 {
redelivered++
}
mu.Unlock()
})
}
}(m)
}
wg.Wait()

dups := 0
for _, k := range accepted {
if k &amp;gt; 1 {
dups += k - 1
}
}
fmt.Printf(&quot;partitions=1 consumers=%d distinct jobs accepted=%d/%d\n&quot;, members, len(accepted), total)
fmt.Printf(&quot;accepted per consumer=%v\n&quot;, perMember)
fmt.Printf(&quot;accepted on a 2nd+ delivery (after RELEASE)=%d, accepted more than once=%d\n&quot;, redelivered, dups)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;{% enddetails %}&lt;/p&gt;
&lt;p&gt;For the Go demo: &lt;code&gt;go mod init sharedemo &amp;amp;&amp;amp; go get github.com/twmb/franz-go@v1.22.1 github.com/twmb/franz-go/pkg/kfake@v0.0.0-20260927204940-b5a45ccfdf7e &amp;amp;&amp;amp; go run .&lt;/code&gt; (the kfake module requires Go 1.26, which the &lt;code&gt;go&lt;/code&gt; command downloads automatically if your toolchain is older).&lt;/p&gt;
&lt;h3 id=&quot;takeaway&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;Kafka 4.2 fixed the strongest technical objection to &quot;Kafka as a queue.&quot; It did not change where your jobs are born. If they are born in a Postgres transaction, keep them there, and spend your effort on the one thing that kills Postgres queues: old snapshots. When the events already live in Kafka and many teams read them, share groups are now a legitimate queue, on Java and Go today, and on Python once the client leaves Preview.&lt;/p&gt;
&lt;h3 id=&quot;sources&quot;&gt;Sources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Apache Kafka 4.2.0 release announcement (Feb 17, 2026): &lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/blog/2026/02/17/apache-kafka-4.2.0-release-announcement/&quot;&gt;https://kafka.apache.org/blog/2026/02/17/apache-kafka-4.2.0-release-announcement/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Apache Kafka 4.3.0 release announcement (May 22, 2026): &lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/blog/2026/05/22/apache-kafka-4.3.0-release-announcement/&quot;&gt;https://kafka.apache.org/blog/2026/05/22/apache-kafka-4.3.0-release-announcement/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;KIP-932, Queues for Kafka: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cwiki.apache.org/confluence/display/KAFKA/KIP-932%3A+Queues+for+Kafka&quot;&gt;https://cwiki.apache.org/confluence/display/KAFKA/KIP-932: Queues for Kafka&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Kafka 4.3 broker configs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/43/configuration/broker-configs/&quot;&gt;https://kafka.apache.org/43/configuration/broker-configs/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Kafka 4.3 group configs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/43/configuration/group-configs/&quot;&gt;https://kafka.apache.org/43/configuration/group-configs/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;KafkaShareConsumer javadoc (4.2): &lt;a rel=&quot;nofollow&quot; href=&quot;https://kafka.apache.org/42/javadoc/org/apache/kafka/clients/consumer/KafkaShareConsumer.html&quot;&gt;https://kafka.apache.org/42/javadoc/org/apache/kafka/clients/consumer/KafkaShareConsumer.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Kafka 4.4.0 release plan (KIP-1191 status): &lt;a rel=&quot;nofollow&quot; href=&quot;https://cwiki.apache.org/confluence/spaces/KAFKA/pages/429064575/Release+Plan+4.4.0&quot;&gt;https://cwiki.apache.org/confluence/spaces/KAFKA/pages/429064575/Release Plan 4.4.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Confluent, Queues for Kafka GA: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.confluent.io/blog/kafka-queue-semantics-share-consumer-ga/&quot;&gt;https://www.confluent.io/blog/kafka-queue-semantics-share-consumer-ga/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;franz-go changelog: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/twmb/franz-go/blob/master/CHANGELOG.md&quot;&gt;https://github.com/twmb/franz-go/blob/master/CHANGELOG.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;confluent-kafka-python v2.15.0: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/confluentinc/confluent-kafka-python/releases/tag/v2.15.0&quot;&gt;https://github.com/confluentinc/confluent-kafka-python/releases/tag/v2.15.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;librdkafka v2.15.0: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/confluentinc/librdkafka/releases/tag/v2.15.0&quot;&gt;https://github.com/confluentinc/librdkafka/releases/tag/v2.15.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL SELECT (locking clause, SKIP LOCKED): &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/sql-select.html&quot;&gt;https://www.postgresql.org/docs/current/sql-select.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL routine vacuuming: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/routine-vacuuming.html&quot;&gt;https://www.postgresql.org/docs/current/routine-vacuuming.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL NOTIFY: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/sql-notify.html&quot;&gt;https://www.postgresql.org/docs/current/sql-notify.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL client connection defaults (timeouts): &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/runtime-config-client.html&quot;&gt;https://www.postgresql.org/docs/current/runtime-config-client.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL replication config (hot_standby_feedback): &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/current/runtime-config-replication.html&quot;&gt;https://www.postgresql.org/docs/current/runtime-config-replication.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL 17 release notes (transaction_timeout): &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/17/release-17.html&quot;&gt;https://www.postgresql.org/docs/17/release-17.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL 19 draft release notes: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/docs/19/release-19.html&quot;&gt;https://www.postgresql.org/docs/19/release-19.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL 19 RC1 and GA dates: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.postgresql.org/message-id/915517c4-d0ee-49f9-bbeb-c683ef2b9d3f%40postgresql.org&quot;&gt;https://www.postgresql.org/message-id/915517c4-d0ee-49f9-bbeb-c683ef2b9d3f@postgresql.org&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;River, transactional enqueueing: &lt;a rel=&quot;nofollow&quot; href=&quot;https://riverqueue.com/docs/transactional-enqueueing&quot;&gt;https://riverqueue.com/docs/transactional-enqueueing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Solid Queue README: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/rails/solid_queue&quot;&gt;https://github.com/rails/solid_queue&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Debezium outbox event router: &lt;a rel=&quot;nofollow&quot; href=&quot;https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html&quot;&gt;https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30477/kafka-just-learned-to-be-a-queue-postgres-has-been-one-all-along</guid>
<pubDate>Fri, 09 Oct 2026 16:28:36 +0000</pubDate>
</item>
<item>
<title>Building a Resilient Job Queue in Go: Postgres, Worker Pool and Graceful Shutdown</title>
<link>https://coderlegion.com/29442/building-a-resilient-job-queue-in-go-postgres-worker-pool-and-graceful-shutdown</link>
<description>&lt;p&gt;Building a Resilient Job Queue in Go: Postgres, Worker Pool and Graceful Shutdown&lt;/p&gt;
&lt;p&gt;I am Pires Aurélio, Go Backend Engineer from Luanda, Angola. I build production systems in public.&lt;/p&gt;
&lt;p&gt;For two years I built scrapers and simple REST APIs. I got tired of tutorials that only show Hello World. No jobs, no retries, no real production problems.&lt;/p&gt;
&lt;p&gt;So I built a real one: A Production-Ready Background Job Queue in Go.&lt;/p&gt;
&lt;p&gt;This is the production deep-dive companion to my DEV article. All links are in the first comment.&lt;/p&gt;
&lt;p&gt;WHAT WE ARE BUILDING&lt;/p&gt;
&lt;p&gt;A REST API where you POST a task, it goes to Postgres, and Go workers process it in background with retries and graceful shutdown. Like Sidekiq and Celery, but in pure Go.&lt;/p&gt;
&lt;p&gt;Stack: Go + Gin + Postgres + Goroutines + Docker&lt;br&gt;
Features: Clean Architecture, Postgres as queue, Worker Pool with 5 Goroutines, Graceful Shutdown, Exponential Backoff, Supervisor pattern.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;ARCHITECTURE&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Client -&amp;gt; POST /jobs -&amp;gt; API Gin -&amp;gt; Postgres jobs table -&amp;gt; Worker Pool -&amp;gt; Process -&amp;gt; Update Status&lt;/p&gt;
&lt;p&gt;Why Postgres and not Redis? One less service and ACID guarantees. This proves you understand transactions and row-level locking.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;THE PRODUCTION SCHEMA&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Table jobs has: id UUID, payload JSONB, status pending processing done failed, attempts int, max_attempts int, last_error text, created_at and next_retry_at.&lt;/p&gt;
&lt;p&gt;The most important part is the index on status and next_retry_at where status is pending. This makes the picker O log n with 1 million rows. See Image 1.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;THE SAFE PICKER&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The mistake in tutorials is SELECT then UPDATE. Two workers get the same job.&lt;/p&gt;
&lt;p&gt;The solution is an atomic UPDATE where id comes from a sub-select with row-level locking and SKIP LOCKED. Only one worker gets a job. This is what separates toy from production. See Image 1.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;WORKER POOL&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Start with concurrency 5. For each worker start a goroutine. Loop select on ctx Done for shutdown, default picks a job and processes with retry.&lt;/p&gt;
&lt;p&gt;If fails, increment attempts. If attempts &amp;gt;= max, mark failed. Else backoff = 2 power attempts seconds and set next_retry_at.&lt;/p&gt;
&lt;p&gt;See Image 2.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;SUPERVISOR - NEVER LOSE A JOB&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If server crashes while processing, job stays processing forever.&lt;/p&gt;
&lt;p&gt;On startup: Reset all jobs where status is processing and updated_at older than 5 minutes back to pending. This is what real companies do.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;GRACEFUL SHUTDOWN - THE INTERVIEW QUESTION&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Use signal NotifyContext for Interrupt and SIGTERM. Start workers with that context. Start HTTP server in goroutine. Block on ctx Done. On signal, create timeout context 10 seconds and call server Shutdown. Workers stop via ctx Done.&lt;/p&gt;
&lt;p&gt;See Image 3. This proves you know context propagation.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;BENCHMARK&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Python + Celery + Redis: 120 jobs per sec, 180 MB RAM&lt;br&gt;
Go + Postgres: 1850 jobs per sec, 18 MB RAM&lt;/p&gt;
&lt;p&gt;15x faster, 10x less memory.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;WHAT RECRUITERS SEE&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You understand concurrency, you understand databases beyond SELECT, you think about failure, you can build infrastructure not just CRUD.&lt;/p&gt;
&lt;p&gt;Full code with metrics is coming to my GitHub next week. Links in first comment.&lt;/p&gt;
&lt;p&gt;Built from Luanda, Angola.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29442/building-a-resilient-job-queue-in-go-postgres-worker-pool-and-graceful-shutdown</guid>
<pubDate>Thu, 01 Oct 2026 18:08: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-1&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>Next.js Full-Stack Auth Template</title>
<link>https://coderlegion.com/26536/next-js-full-stack-auth-template</link>
<description>&lt;p&gt;Setting up a complete, production-grade auth flow shouldn't take days.&lt;/p&gt;
&lt;p&gt;I built an open-source starter template combining Next.js, Better-Auth, Drizzle ORM, and PostgreSQL to streamline the entire setup.&lt;/p&gt;
&lt;p&gt;What it includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Email/Password &amp;amp; Social OAuth (Google &amp;amp; GitHub)&lt;/li&gt;
&lt;li&gt;PostgreSQL schema management &amp;amp; migrations with Drizzle ORM&lt;/li&gt;
&lt;li&gt;Server-side route protection via Next.js layouts&lt;/li&gt;
&lt;li&gt;Full client and server type safety&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I've shared the entire codebase and published a comprehensive architectural breakdown explaining the full setup step-by-step.&lt;/p&gt;
&lt;p&gt;Live Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://next-js-better-auth-template.vercel.app/&quot;&gt;website-link&lt;/a&gt;&lt;br&gt;
GitHub Repository: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/OnkarK0273/next-better-auth-template&quot;&gt;repo-link&lt;/a&gt;&lt;br&gt;
Full Step-by-Step Guide: &lt;a rel=&quot;nofollow&quot; href=&quot;https://onkark.hashnode.dev/next-js-full-stack-auth-better-auth-drizzle-orm-guide&quot;&gt;article-link&lt;/a&gt;&lt;br&gt;
Video Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://lnkd.in/p/gAXKe4uS&quot;&gt;video-link&lt;/a&gt;&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/26536/next-js-full-stack-auth-template</guid>
<pubDate>Wed, 09 Sep 2026 05:55:26 +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>Directus + Coolify: Should You Decouple Postgres &amp; Redis?</title>
<link>https://coderlegion.com/24235/directus-coolify-should-you-decouple-postgres-redis</link>
<description>&lt;blockquote&gt;&lt;p&gt;This is Part 2 of the Directus + Coolify series. If you're new here, start with &lt;strong&gt;&quot;Secure Your VPS Before Hackers Do&quot;&lt;/strong&gt; and the first Directus + Coolify post — the bundled, single-Compose-file setup — before following along with this one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;introduction-1&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;In the first method, we coupled all of the services into one stack using a single Docker Compose file. The network between all services was created automatically, and we didn't have to start them up individually — which removes the risk of a race condition if service startup isn't handled properly.&lt;/p&gt;
&lt;p&gt;If you're running a single app, that's genuinely the recommended way to set up Directus on a Coolify-managed VPS. Going in, I assumed there were several good reasons to split the services apart instead — more control over backups, monitoring, restarts, that kind of thing. So before recommending decoupling, I actually tested each of those assumptions on a live Coolify instance.&lt;/p&gt;
&lt;p&gt;Most of them turned out to be wrong.&lt;/p&gt;
&lt;h3 id=&quot;myth-1-restarting-directus-restarts-the-whole-stack&quot;&gt;Myth 1: Restarting Directus Restarts the Whole Stack&lt;/h3&gt;
&lt;p&gt;I expected that restarting Directus inside the bundled Compose file would restart Redis and Postgres along with it. It doesn't. Coolify lets you restart each service in the stack independently — Directus, Database, and Cache each get their own &lt;strong&gt;Restart&lt;/strong&gt; button, right there in the same view. No decoupling needed for this one.&lt;/p&gt;
&lt;h3 id=&quot;myth-2-you-need-a-separate-database-resource-for-s3-backups&quot;&gt;Myth 2: You Need a Separate Database Resource for S3 Backups&lt;/h3&gt;
&lt;p&gt;Same story. Even with Postgres bundled inside the Directus Compose file, Coolify still gives it its own dedicated &lt;strong&gt;Backups&lt;/strong&gt; option, S3 included. This isn't a separate-resource-only feature.&lt;/p&gt;
&lt;h3 id=&quot;myth-3-scheduled-tasks-require-separate-services&quot;&gt;Myth 3: Scheduled Tasks Require Separate Services&lt;/h3&gt;
&lt;p&gt;Also not true. Coolify exposes a &lt;strong&gt;Scheduled Tasks&lt;/strong&gt; tab per service, even inside a single bundled stack — complete with a &lt;strong&gt;Container name&lt;/strong&gt; dropdown letting you target the cron job at just the database, or just Directus, without splitting anything apart.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-holds-up&quot;&gt;What Actually Holds Up&lt;/h3&gt;
&lt;p&gt;Two things survived testing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First: metrics.&lt;/strong&gt; This one's confirmed directly in Coolify's own documentation — CPU and memory metrics collection is explicitly not available for Docker Compose–based deployments. If you want to see per-container resource usage through Coolify's built-in monitoring, the service needs to be created as its own standalone resource, not bundled inside a Compose file. This is a real, documented limitation of the bundled approach.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second: sharing a database across multiple apps.&lt;/strong&gt; This one isn't a Coolify feature at all — it's just how Docker networking works. A database defined inside one app's Compose file lives on that stack's own private network by default. A second, completely separate application can't reach it without deliberately bridging the two networks. If you've got a desktop app and a mobile app that both need to talk to the same Postgres instance, that database needs to exist as its own standalone resource from the start — it can't stay tucked inside one app's Compose file.&lt;/p&gt;
&lt;p&gt;So this really comes down to two reasons to decouple, not five — one a genuine Coolify limitation, the other a structural fact about Docker networking. Let's set both of those up properly.&lt;/p&gt;
&lt;h3 id=&quot;adding-the-resources&quot;&gt;Adding the Resources&lt;/h3&gt;
&lt;h4 id=&quot;adding-the-postgresql-database&quot;&gt;Adding the PostgreSQL Database&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dashboard → Add Project&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;+ Add Resource&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Databases → PostgreSQL → Supabase PostgreSQL (with extensions)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Change the name to something human-friendly&lt;/li&gt;
&lt;li&gt;Copy your username and password and save them somewhere — you'll need them shortly&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Start&lt;/strong&gt;, and wait for the database to spin up (this can take a little while)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once it's up, the status should read &lt;strong&gt;&quot;Running (Healthy).&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Enable metrics while you're here.&lt;/strong&gt; In the sidebar, go to &lt;strong&gt;Servers → localhost → Metrics&lt;/strong&gt;, and enable metrics. Back in your project, under &lt;strong&gt;Databases&lt;/strong&gt;, click your Postgres database, then &lt;strong&gt;Metrics&lt;/strong&gt; — you should now see live CPU/memory usage for it. This is the exact capability that isn't available on a bundled Compose deployment.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;adding-redis-cache&quot;&gt;Adding Redis Cache&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dashboard → + Add Resource&lt;/strong&gt; (from the project itself)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Databases → Redis&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Rename it to something more convenient&lt;/li&gt;
&lt;li&gt;Copy the Redis connection URL — you'll need it shortly&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;, then &lt;strong&gt;Start&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once both are running, SSH into your VPS and run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;to confirm both containers are up.&lt;/p&gt;
&lt;h4 id=&quot;the-docker-compose-config-for-directus&quot;&gt;The Docker Compose Config for Directus&lt;/h4&gt;
&lt;p&gt;This is what goes into the empty Compose file for the Directus resource:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  directus:
    image: 'directus/directus:12.2.0'
    ports:
      - '8055:8055'
    volumes:
      - './uploads:/directus/uploads'
      - './extensions:/directus/extensions'
    healthcheck:
      test:
        - CMD-SHELL
        - 'wget --spider -q http://127.0.0.1:8055/server/ping || exit 1'
      interval: 10s
      timeout: 5s
      retries: 5
      start_interval: 5s
      start_period: 30s
    environment:
      SECRET: secretstring
      MARKETPLACE_TRUST: all
      DB_CLIENT: pg
      DB_HOST:
      DB_PORT: '5432'
      DB_DATABASE: postgres
      DB_USER: postgres
      DB_PASSWORD:
      CACHE_ENABLED: 'true'
      CACHE_STORE: redis
      CACHE_AUTO_PURGE: 'true'
      REDIS:
      ADMIN_EMAIL: *Emails are not allowed*
      ADMIN_PASSWORD: '1234567890'
      CORS_ENABLED: 'true'
      CORS_ORIGIN: 'true'
      CORS_CREDENTIALS: 'true'
      PUBLIC_URL:
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;Notice the healthcheck already uses &lt;code&gt;127.0.0.1&lt;/code&gt; instead of &lt;code&gt;localhost&lt;/code&gt; — that's the fix from Part 1. Carrying it forward here saves you from hitting the exact same &quot;unhealthy&quot; bug all over again.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;adding-directus&quot;&gt;Adding Directus&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dashboard → + Add Resource&lt;/strong&gt; (from the project itself)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Applications → Docker Compose Empty&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Paste in the Compose configuration above&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Network → Connect To Predefined Network&lt;/strong&gt; → check the box&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Services → Directus service → Settings&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Add your Directus subdomain — remember to use &lt;code&gt;https://&lt;/code&gt; (e.g. &lt;code&gt;https://directus.yourdomain.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;connecting-the-services-together&quot;&gt;Connecting the Services Together&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Variable &lt;/th&gt;&lt;th&gt;What it is&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;SECRET&lt;/code&gt; &lt;/td&gt;&lt;td&gt;A long, unguessable random string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DB_HOST&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The name of your Postgres container — run &lt;code&gt;docker ps&lt;/code&gt; on your VPS to find it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DB_DATABASE&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The name of the database on your Postgres server (Coolify's default is usually &lt;code&gt;postgres&lt;/code&gt;, but confirm it against your Postgres service)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DB_USER&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The database username, from the Postgres service you created earlier&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DB_PASSWORD&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The password from that same Postgres service&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;REDIS&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The Redis connection URL from the Redis service you created earlier&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PUBLIC_URL&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Your Directus subdomain&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Redis URL gotcha:&lt;/strong&gt; the connection URL follows the format &lt;code&gt;redis://username:password@host:port&lt;/code&gt;. Coolify's generated URL includes the username you set when creating the Redis resource — in my case, that username was also &lt;code&gt;redis&lt;/code&gt;, so the URL looked like &lt;code&gt;redis://redis:somelongvariable...&lt;/code&gt;. Directus doesn't need the username here, just the password, so strip that segment out: &lt;code&gt;redis://:somelongvariable...&lt;/code&gt;. If you used a different username when creating your Redis resource, remove &lt;em&gt;that&lt;/em&gt; value instead — not literally the word &quot;redis.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;setting-up-environment-variables&quot;&gt;Setting Up Environment Variables&lt;/h3&gt;
&lt;p&gt;Rather than hardcoding any of this directly into the Compose file, move it into Directus's environment variables:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dashboard → Projects → Services / Directus&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment Variables&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;+ Add&lt;/strong&gt;, enter the variable name in all caps, and its value&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Repeat for: &lt;code&gt;SECRET&lt;/code&gt;, &lt;code&gt;DB_HOST&lt;/code&gt;, &lt;code&gt;DB_DATABASE&lt;/code&gt;, &lt;code&gt;DB_USER&lt;/code&gt;, &lt;code&gt;DB_PASSWORD&lt;/code&gt;, &lt;code&gt;ADMIN_EMAIL&lt;/code&gt;, &lt;code&gt;ADMIN_PASSWORD&lt;/code&gt;, and &lt;code&gt;REDIS&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;updating-the-compose-file&quot;&gt;Updating the Compose File&lt;/h4&gt;
&lt;p&gt;Now reference those variables instead of the raw values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;SECRET: '${SECRET}'
DB_HOST: '${DB_HOST}'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;...and so on for each variable. Then:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Restart&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;launching-directus&quot;&gt;Launching Directus&lt;/h3&gt;
&lt;p&gt;Paste your Directus subdomain into the browser, and you should land on your running Directus instance. You can also click &lt;strong&gt;Links&lt;/strong&gt; on the Directus service, then click the subdomain — it'll take you straight to your Directus Studio login.&lt;/p&gt;
&lt;p&gt;Same as the last video: sign up for your free license, which arrives by email, and paste it into your Directus instance to unlock everything.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Any questions or hit a different result testing any of these yourself? Drop it in the comments — I'm genuinely curious whether this holds up across different Coolify versions and setups.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24235/directus-coolify-should-you-decouple-postgres-redis</guid>
<pubDate>Thu, 06 Aug 2026 21:39:55 +0000</pubDate>
</item>
<item>
<title>🚀 UUIDv4 vs UUIDv7 in PostgreSQL</title>
<link>https://coderlegion.com/22901/uuidv4-vs-uuidv7-in-postgresql</link>
<description>&lt;h4 id=&quot;why-switching-uuid-versions-can-boost-performance-at-scale&quot;&gt;Why Switching UUID Versions Can Boost Performance at Scale&lt;/h4&gt;
&lt;p&gt;Many teams choose UUIDs as primary keys to support distributed systems and avoid ID collisions.&lt;br&gt;
But &lt;strong&gt;not all UUIDs are equal&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Some teams have seen &lt;strong&gt;10×–50× performance improvements&lt;/strong&gt; simply by switching from &lt;strong&gt;UUIDv4 to UUIDv7&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Sounds surprising?&lt;br&gt;
Let’s break it down - step by step - with &lt;strong&gt;no hand-waving&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-what-this-blog-will-help-you-decide&quot;&gt;🎯 What This Blog Will Help You Decide&lt;/h3&gt;
&lt;p&gt;By the end, you’ll understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What UUIDv4 and UUIDv7 actually are&lt;/li&gt;
&lt;li&gt;How PostgreSQL indexes work internally&lt;/li&gt;
&lt;li&gt;Why random IDs hurt performance&lt;/li&gt;
&lt;li&gt;Why UUIDv7 scales dramatically better&lt;/li&gt;
&lt;li&gt;When you should (and shouldn’t) switch&lt;/li&gt;
&lt;li&gt;How to benchmark this yourself&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-quick-refresher-what-is-a-uuid-&quot;&gt;1️⃣ Quick Refresher: What Is a UUID?&lt;/h3&gt;
&lt;p&gt;A UUID (Universally Unique Identifier) is a &lt;strong&gt;128-bit value&lt;/strong&gt; designed to be globally unique.&lt;/p&gt;
&lt;p&gt;Why developers use UUIDs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Safe for distributed systems&lt;/li&gt;
&lt;li&gt;Can be generated client-side&lt;/li&gt;
&lt;li&gt;No central ID generator&lt;/li&gt;
&lt;li&gt;Avoids ID collisions across services&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;PostgreSQL stores UUIDs efficiently (16 bytes), so &lt;strong&gt;UUIDs themselves are not slow&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;👉 The &lt;strong&gt;problem is the insertion pattern&lt;/strong&gt;, not the type.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-uuidv4---random-by-design&quot;&gt;2️⃣ UUIDv4 - Random by Design&lt;/h3&gt;
&lt;h4 id=&quot;how-uuidv4-works&quot;&gt;How UUIDv4 Works&lt;/h4&gt;
&lt;p&gt;UUIDv4 is &lt;strong&gt;purely random&lt;/strong&gt; (122 random bits).&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;9f1c0d3a-4b6e-4b8f-8f91-17e0a0c9a721
e2a31f8b-91f2-4df7-98cb-6e9fcae721aa
0d2c5f99-5d8c-4b6d-8a0e-2dbbfa17d441
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key properties:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No ordering&lt;/li&gt;
&lt;li&gt;No timestamp&lt;/li&gt;
&lt;li&gt;Completely scattered values&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This randomness is &lt;strong&gt;great for uniqueness&lt;/strong&gt;&lt;br&gt;
…but &lt;strong&gt;terrible for database indexes&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-uuidv7---time-ordered-uuids&quot;&gt;3️⃣ UUIDv7 - Time-Ordered UUIDs&lt;/h3&gt;
&lt;p&gt;UUIDv7 is a &lt;strong&gt;newer standard&lt;/strong&gt; designed specifically for modern databases.&lt;/p&gt;
&lt;h4 id=&quot;how-uuidv7-works&quot;&gt;How UUIDv7 Works&lt;/h4&gt;
&lt;p&gt;UUIDv7 combines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;timestamp (milliseconds)&lt;/strong&gt; in the most significant bits&lt;/li&gt;
&lt;li&gt;Random bits for uniqueness&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example (simplified):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;018f3b3a-9b5c-7d12-bc01-9a2cfe123456
018f3b3a-9b5d-7f44-a231-acde441298ab
018f3b3a-9b5e-82a1-9d22-acde112341aa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key properties:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mostly increasing over time&lt;/li&gt;
&lt;li&gt;Still globally unique&lt;/li&gt;
&lt;li&gt;Still safe for distributed systems&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;👉 This ordering changes everything.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-how-postgresql-stores-and-indexes-data-critical-part-&quot;&gt;4️⃣ How PostgreSQL Stores and Indexes Data (Critical Part)&lt;/h3&gt;
&lt;h4 id=&quot;postgresql-table-storage&quot;&gt;PostgreSQL Table Storage&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Rows are stored in a &lt;strong&gt;heap&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Order in heap is not guaranteed&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;postgresql-indexes&quot;&gt;PostgreSQL Indexes&lt;/h4&gt;
&lt;p&gt;Primary keys use &lt;strong&gt;B-tree indexes&lt;/strong&gt; by default.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-what-is-a-b-tree-in-simple-terms-&quot;&gt;5️⃣ What Is a B-Tree (In Simple Terms)&lt;/h3&gt;
&lt;p&gt;A B-tree:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Stores keys in &lt;strong&gt;sorted order&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Is optimized for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sequential inserts&lt;/li&gt;
&lt;li&gt;Range scans&lt;/li&gt;
&lt;li&gt;Cache-friendly access&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Think of it like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A well-organized book where new pages are best added at the end.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-why-uuidv4-hurts-postgresql-performance&quot;&gt;6️⃣ Why UUIDv4 Hurts PostgreSQL Performance&lt;/h3&gt;
&lt;p&gt;With UUIDv4:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Each new ID belongs &lt;strong&gt;somewhere random&lt;/strong&gt; in the index&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PostgreSQL must:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Find the correct position&lt;/li&gt;
&lt;li&gt;Split pages frequently&lt;/li&gt;
&lt;li&gt;Rebalance the tree&lt;/li&gt;
&lt;li&gt;Touch random memory pages&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;consequences-&quot;&gt;Consequences:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Heavy page splits&lt;/li&gt;
&lt;li&gt;Index fragmentation&lt;/li&gt;
&lt;li&gt;Poor cache locality&lt;/li&gt;
&lt;li&gt;Increased disk I/O&lt;/li&gt;
&lt;li&gt;Slower inserts&lt;/li&gt;
&lt;li&gt;Slower reads&lt;/li&gt;
&lt;li&gt;Slower vacuum&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This gets &lt;strong&gt;worse as the table grows&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-why-uuidv7-is-fast&quot;&gt;7️⃣ Why UUIDv7 Is Fast&lt;/h3&gt;
&lt;p&gt;With UUIDv7:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New rows are &lt;strong&gt;mostly appended&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Inserts hit the &lt;strong&gt;rightmost leaf page&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Minimal page splits&lt;/li&gt;
&lt;li&gt;Excellent cache locality&lt;/li&gt;
&lt;li&gt;Sequential disk writes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is very similar to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;BIGSERIAL&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IDENTITY&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Snowflake-style IDs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;👉 PostgreSQL &lt;em&gt;loves&lt;/em&gt; this pattern.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-why-teams-see-10-50-improvements&quot;&gt;8️⃣ Why Teams See 10×–50× Improvements&lt;/h3&gt;
&lt;p&gt;At scale (millions of rows):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric           &lt;/th&gt;&lt;th&gt;UUIDv4   &lt;/th&gt;&lt;th&gt;UUIDv7&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Insert latency   &lt;/td&gt;&lt;td&gt;High     &lt;/td&gt;&lt;td&gt;Low&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index size       &lt;/td&gt;&lt;td&gt;Large    &lt;/td&gt;&lt;td&gt;Smaller&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache efficiency &lt;/td&gt;&lt;td&gt;Poor     &lt;/td&gt;&lt;td&gt;Excellent&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Page splits      &lt;/td&gt;&lt;td&gt;Frequent &lt;/td&gt;&lt;td&gt;Rare&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Vacuum cost      &lt;/td&gt;&lt;td&gt;High     &lt;/td&gt;&lt;td&gt;Lower&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;⚠️ Important note:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;50× is workload-dependent&lt;/li&gt;
&lt;li&gt;Typical gains are &lt;strong&gt;5×–20×&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Still very significant&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-hands-on-benchmark-you-can-try-this-&quot;&gt;9️⃣ Hands-On Benchmark (You Can Try This)&lt;/h3&gt;
&lt;h4 id=&quot;table-setup&quot;&gt;Table Setup&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE test_uuid_v4 (
  id UUID PRIMARY KEY,
  data TEXT
);

CREATE TABLE test_uuid_v7 (
  id UUID PRIMARY KEY,
  data TEXT
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;insert-uuidv4&quot;&gt;Insert UUIDv4&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO test_uuid_v4
SELECT gen_random_uuid(), 'data'
FROM generate_series(1, 1000000);
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;insert-uuidv7-postgres-16-or-extension-&quot;&gt;Insert UUIDv7 (Postgres 16+ or extension)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO test_uuid_v7
SELECT uuidv7(), 'data'
FROM generate_series(1, 1000000);
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;UUIDv7 generation requires Postgres 18 (&lt;code&gt;uuidv7()&lt;/code&gt;) or an extension such as &lt;code&gt;pg_uuidv7&lt;/code&gt; in earlier versions.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;observe-&quot;&gt;Observe:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Insert time&lt;/li&gt;
&lt;li&gt;Index size&lt;/li&gt;
&lt;li&gt;CPU usage&lt;/li&gt;
&lt;li&gt;Disk writes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You’ll see:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;UUIDv7 inserts are dramatically smoother&lt;/li&gt;
&lt;li&gt;Index size is smaller&lt;/li&gt;
&lt;li&gt;Less I/O pressure&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-why-this-matters-in-real-systems&quot;&gt;🔍 Why This Matters in Real Systems&lt;/h3&gt;
&lt;h4 id=&quot;backend-impact&quot;&gt;Backend Impact&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Faster writes&lt;/li&gt;
&lt;li&gt;Better read performance&lt;/li&gt;
&lt;li&gt;Healthier indexes&lt;/li&gt;
&lt;li&gt;Lower infrastructure cost&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;frontend-impact&quot;&gt;Frontend Impact&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;IDs sort naturally by creation time&lt;/li&gt;
&lt;li&gt;Easier pagination&lt;/li&gt;
&lt;li&gt;Better caching behavior&lt;/li&gt;
&lt;li&gt;Cleaner URLs&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-when-uuidv7-is-not-the-right-choice&quot;&gt;⚠️ When UUIDv7 Is NOT the Right Choice&lt;/h3&gt;
&lt;p&gt;Be honest and balanced:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you need &lt;strong&gt;fully random IDs&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;If timestamp leakage is a concern&lt;/li&gt;
&lt;li&gt;If your dataset is small&lt;/li&gt;
&lt;li&gt;If sequential IDs are unacceptable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;UUIDv7 is a &lt;strong&gt;performance trade-off&lt;/strong&gt;, not magic.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-final-thoughts&quot;&gt;🏁 Final Thoughts&lt;/h3&gt;
&lt;p&gt;This performance boost isn’t accidental — it’s &lt;strong&gt;how databases work&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;the-takeaway-&quot;&gt;The takeaway:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;PostgreSQL B-trees love ordered inserts&lt;/li&gt;
&lt;li&gt;UUIDv4 is random → bad at scale&lt;/li&gt;
&lt;li&gt;UUIDv7 is time-ordered → excellent at scale&lt;/li&gt;
&lt;li&gt;Schema design decisions matter more than hardware&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Choosing the right ID strategy early can save &lt;strong&gt;years of performance tuning later&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;💬 &lt;strong&gt;Have you used UUIDv4 in production and faced scaling issues?&lt;/strong&gt;&lt;br&gt;
Or already switched to UUIDv7?&lt;/p&gt;
&lt;p&gt;Let’s discuss — real-world experiences help everyone.&lt;/p&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22901/uuidv4-vs-uuidv7-in-postgresql</guid>
<pubDate>Fri, 17 Jul 2026 17:21:52 +0000</pubDate>
</item>
<item>
<title>Chai aur SQL — A Beginner's Journey into Databases</title>
<link>https://coderlegion.com/21811/chai-aur-sql-a-beginners-journey-into-databases</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Chai aur SQL — A Beginner's Journey into Databases&lt;br&gt;
published: true&lt;br&gt;
canonical_url: &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.abdulrdeveloper.me/chai-aur-sql-a-beginner-s-journey-into-databases&quot;&gt;https://blog.abdulrdeveloper.me/chai-aur-sql-a-beginner-s-journey-into-databases&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;tags-sql-postgresql-database-backend&quot;&gt;tags: sql, postgresql, database, backend&lt;/h3&gt;
&lt;p&gt;SQL stands for Structured Query Language. It is the language we use to talk to relational databases like MySQL, PostgreSQL, and SQLite.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-a-server-and-where-does-the-database-live-&quot;&gt;What is a Server and Where Does the Database Live?&lt;/h3&gt;
&lt;p&gt;Most beginners think the database is somewhere floating in the &quot;cloud.&quot; It is not.&lt;/p&gt;
&lt;p&gt;A database is software running on a server. A server is just a powerful computer that runs 24/7 inside a data center — like Amazon AWS, Google Cloud, or DigitalOcean. When that software saves your data, it writes it to actual files on the server's SSD or hard drive.&lt;/p&gt;
&lt;p&gt;MySQL and PostgreSQL save data as &lt;code&gt;.ibd&lt;/code&gt; files or WAL (Write-Ahead Log) files. MongoDB saves as &lt;code&gt;.bson&lt;/code&gt; files. These files sit on disk just like any file on your laptop.&lt;/p&gt;
&lt;p&gt;When you write this in your backend code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const pool = new Pool({ host: 'localhost', port: 5432 })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You are telling your backend to open a connection to the port where the database is listening. That is it. It is a TCP connection — like a phone call between your application and the database.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-docker-for-postgresql-&quot;&gt;Why Docker for PostgreSQL?&lt;/h3&gt;
&lt;p&gt;Instead of installing PostgreSQL directly on my system, I used Docker. Docker creates an isolated environment (called a container) so I do not mess up my laptop's setup.&lt;/p&gt;
&lt;p&gt;Here is the command I used to create a PostgreSQL container with persistent data:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mac / Linux / WSL:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run -d --name sql_class \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=sql_class_01_db \
  -p 5433:5432 \
  -v &quot;$(pwd)/pgdata:/var/lib/postgresql/data&quot; \
  postgres
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Windows (PowerShell):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;docker run -d --name sql_class -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=sql_class_01_db -p 5433:5432 -v &quot;${PWD}/pgdata:/var/lib/postgresql/data&quot; postgres
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-v&lt;/code&gt; flag maps a &lt;code&gt;pgdata&lt;/code&gt; folder inside your current directory to the container's storage. So even if the container is deleted, your data stays on your laptop.&lt;/p&gt;
&lt;p&gt;To check if the container is running:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To remove the container:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker rm -f sql_class
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-learn-raw-sql-when-we-have-prisma-and-drizzle-&quot;&gt;Why Learn Raw SQL When We Have Prisma and Drizzle?&lt;/h3&gt;
&lt;p&gt;This was my first question. Why learn raw SQL when Prisma can generate the queries for me?&lt;/p&gt;
&lt;p&gt;Here is what I found out:&lt;/p&gt;
&lt;p&gt;ORMs like Prisma and Drizzle translate your JavaScript/TypeScript code into SQL strings and send them to the database. If you do not know SQL, you do not know what Prisma is actually doing behind the scene.&lt;/p&gt;
&lt;p&gt;The N+1 problem is a real thing. ORMs sometimes generate 100 queries instead of 1 optimized query. You will only catch this if you understand SQL.&lt;/p&gt;
&lt;p&gt;For complex queries — joining 6 tables, generating financial reports — ORM syntax breaks down. Developers fall back to raw SQL.&lt;/p&gt;
&lt;p&gt;And SQL has not changed in 50 years. Sequelize came and went. Prisma came. Drizzle came. SQL is still the same.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;ddl-how-you-create-tables&quot;&gt;DDL — How You Create Tables&lt;/h3&gt;
&lt;p&gt;DDL stands for Data Definition Language. These are the commands that define the structure of your database — not the data, just the structure.&lt;/p&gt;
&lt;p&gt;The four main DDL commands are &lt;code&gt;CREATE&lt;/code&gt;, &lt;code&gt;ALTER&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, and &lt;code&gt;TRUNCATE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here is a real table I created while practicing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE students (
    student_id SERIAL PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50),
    email VARCHAR(100) UNIQUE NOT NULL,
    phone_number CHAR(10) UNIQUE,
    age INT CHECK (age &amp;gt; 12),
    current_status VARCHAR(20) DEFAULT 'active'
        CHECK (current_status IN ('active', 'graduated', 'dropped_out', 'on_leave')),
    has_joined_masterji BOOLEAN DEFAULT FALSE,
    current_score NUMERIC(5, 2) CHECK (current_score &amp;gt;= 0 AND current_score &amp;lt;= 100),
    enrollment_date DATE DEFAULT CURRENT_DATE
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SERIAL&lt;/code&gt; means auto-incrementing integer — PostgreSQL handles the ID for you. &lt;code&gt;NOT NULL&lt;/code&gt; means the column cannot be empty. &lt;code&gt;UNIQUE&lt;/code&gt; means no two rows can have the same value. &lt;code&gt;CHECK&lt;/code&gt; is a custom condition — here age must be greater than 12. &lt;code&gt;DEFAULT&lt;/code&gt; sets a value automatically if nothing is provided.&lt;/p&gt;
&lt;p&gt;To add a new column to an existing table later:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ALTER TABLE students ADD COLUMN batch_name VARCHAR(50) DEFAULT 'Web Dev 2026';
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;select-queries-reading-your-data&quot;&gt;SELECT Queries — Reading Your Data&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SELECT&lt;/code&gt; is the command to read data from a table.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Get everything
SELECT * FROM ipl_players;

-- Get specific columns
SELECT name, team, auction_price_crores FROM ipl_players;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Filtering with WHERE:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Basic condition
SELECT * FROM ipl_players WHERE team = 'Mumbai Indians';

-- Multiple conditions
SELECT * FROM ipl_players WHERE role = 'All-Rounder' AND wickets_taken &amp;gt; 10;

-- Either condition
SELECT * FROM ipl_players WHERE team = 'CSK' OR team = 'RCB';

-- Range
SELECT * FROM ipl_players WHERE auction_price_crores BETWEEN 5 AND 12;

-- From a list
SELECT * FROM ipl_players WHERE team NOT IN ('Mumbai Indians', 'CSK', 'RCB');

-- Pattern matching — names starting with R
SELECT * FROM ipl_players WHERE name LIKE 'R%';

-- Check for empty values
SELECT * FROM ipl_players WHERE team IS NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;%&lt;/code&gt; means zero or more characters. &lt;code&gt;_&lt;/code&gt; means exactly one character. &lt;code&gt;LIKE&lt;/code&gt; is case-sensitive. &lt;code&gt;ILIKE&lt;/code&gt; is case-insensitive (PostgreSQL only).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sorting and limiting:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Sort by price, highest first
SELECT name, auction_price_crores FROM ipl_players ORDER BY auction_price_crores DESC;

-- Top 3 results
SELECT name, auction_price_crores FROM ipl_players ORDER BY auction_price_crores DESC LIMIT 3;

-- Skip first 3, get next 3 (pagination)
SELECT name, auction_price_crores FROM ipl_players ORDER BY auction_price_crores DESC LIMIT 3 OFFSET 3;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;dml-insert-update-delete&quot;&gt;DML — Insert, Update, Delete&lt;/h3&gt;
&lt;p&gt;DML stands for Data Manipulation Language. This is how you actually work with the data inside the tables.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;INSERT:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Single row
INSERT INTO canteen_menu (item_name, category, price)
VALUES ('Vada Pav', 'Snacks', 15);

-- Multiple rows at once
INSERT INTO canteen_menu (item_name, category, price) VALUES
('Masala Chai', 'Beverages', 10),
('Samosa', 'Snacks', 12),
('Maggi', 'Snacks', 25);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;UPDATE:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE canteen_menu
SET price = 20
WHERE item_name = 'Vada Pav';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;DELETE:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;DELETE FROM canteen_menu WHERE item_name = 'Cold Coffee';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always use &lt;code&gt;WHERE&lt;/code&gt; with &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;. If you forget the WHERE clause, SQL will update or delete every single row in the table. I learned this the hard way in practice.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;aggregate-functions-count-sum-avg-max-min&quot;&gt;Aggregate Functions — COUNT, SUM, AVG, MAX, MIN&lt;/h3&gt;
&lt;p&gt;Aggregate functions take a whole column and return one number.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- How many rows?
SELECT COUNT(*) FROM smart_watch_sales;

-- Total revenue
SELECT SUM(units_sold * price_per_unit) AS total_revenue FROM smart_watch_sales;

-- Average price
SELECT AVG(price_per_unit) AS avg_price FROM smart_watch_sales;

-- Cheapest and most expensive
SELECT MIN(price_per_unit) AS cheapest, MAX(price_per_unit) AS costliest FROM smart_watch_sales;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GROUP BY&lt;/code&gt; lets you split these calculations by category:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Total units sold per brand
SELECT brand, SUM(units_sold) AS total_units_sold
FROM smart_watch_sales
GROUP BY brand
ORDER BY total_units_sold DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;HAVING&lt;/code&gt; filters the groups after they are created. &lt;code&gt;WHERE&lt;/code&gt; cannot do this because WHERE runs before grouping:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Only show brands that sold more than 20 units total
SELECT brand, SUM(units_sold) AS total_units
FROM smart_watch_sales
GROUP BY brand
HAVING SUM(units_sold) &amp;gt; 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;joins-connecting-multiple-tables&quot;&gt;Joins — Connecting Multiple Tables&lt;/h3&gt;
&lt;p&gt;This is where relational databases get powerful.&lt;/p&gt;
&lt;p&gt;We keep data in separate tables to avoid duplication. Students in one table, internships in another. Joins let you combine them in one query.&lt;/p&gt;
&lt;p&gt;Foreign Key is what connects the tables. The internships table has a &lt;code&gt;student_id&lt;/code&gt; column that references the &lt;code&gt;student_id&lt;/code&gt; in the students table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE internships (
    internship_id SERIAL PRIMARY KEY,
    student_id INT REFERENCES students(student_id) ON DELETE SET NULL,
    company_name VARCHAR(100),
    role VARCHAR(50),
    stipend INT,
    status VARCHAR(20)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ON DELETE SET NULL&lt;/code&gt; means if a student is deleted, the internship record stays but &lt;code&gt;student_id&lt;/code&gt; becomes NULL. &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; would delete the internship too.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;INNER JOIN — only matching rows:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT s.name, s.branch, i.company_name, i.role
FROM students s
INNER JOIN internships i ON s.student_id = i.student_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Students without internships will not show up here.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LEFT JOIN — all students, matched or not:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    s.name,
    s.branch,
    COALESCE(i.company_name, 'No Internship') AS company_name,
    COALESCE(i.stipend, 0) AS stipend
FROM students s
LEFT JOIN internships i ON s.student_id = i.student_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;COALESCE&lt;/code&gt; replaces NULL with a default value. Students without internships will appear with 'No Internship' instead of NULL.&lt;/p&gt;
&lt;p&gt;To find students who have NOT applied anywhere:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT s.name, s.email
FROM students s
LEFT JOIN internships i ON s.student_id = i.student_id
WHERE i.internship_id IS NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;RIGHT JOIN&lt;/strong&gt; returns all rows from the right table. In practice, developers rarely use RIGHT JOIN — they just flip the table order and use LEFT JOIN instead. It reads more naturally.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FULL OUTER JOIN — everything from both tables:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT s.name AS student_name, i.company_name
FROM students s
FULL OUTER JOIN internships i ON s.student_id = i.student_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This shows all students and all internships. Where there is no match on either side, you get NULL.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;indexes-making-queries-fast&quot;&gt;Indexes — Making Queries Fast&lt;/h3&gt;
&lt;p&gt;I inserted 1,000,000 rows into a table to test this.&lt;/p&gt;
&lt;p&gt;Without an index, when you search by name, PostgreSQL scans every single row from top to bottom. This is called a Sequential Scan.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;EXPLAIN ANALYZE SELECT marks FROM marks WHERE name = '809E15792322';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; shows you the query plan and execution time. Before adding an index, my query took around 40-50 milliseconds on 1 million rows.&lt;/p&gt;
&lt;p&gt;After creating an index:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX idx_name ON marks (name);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same query dropped to under 1 millisecond. PostgreSQL now uses a B-Tree structure to find the row directly instead of scanning everything.&lt;/p&gt;
&lt;p&gt;A covering index goes one step further. If your query asks for &lt;code&gt;marks&lt;/code&gt; along with &lt;code&gt;name&lt;/code&gt;, you can store both in the index itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX idx_name ON marks (name) INCLUDE (marks);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now PostgreSQL does not even need to visit the main table. It gets everything from the index directly. This is called an Index Only Scan.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;transactions-and-acid&quot;&gt;Transactions and ACID&lt;/h3&gt;
&lt;p&gt;A transaction is a way to group multiple SQL operations into one unit of work. Either all of them succeed, or none of them do.&lt;/p&gt;
&lt;p&gt;The classic example is a bank transfer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

    UPDATE accounts SET balance = balance - 500 WHERE owner = 'Shubham';
    UPDATE accounts SET balance = balance + 500 WHERE owner = 'Hitesh';

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the server crashes between the two UPDATE statements, the database will rollback both changes. The money will not disappear from Shubham's account and fail to reach Hitesh.&lt;/p&gt;
&lt;p&gt;If something goes wrong, you can manually undo everything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;
    UPDATE accounts SET balance = balance - 5000 WHERE owner = 'Shubham';
ROLLBACK;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;ACID&lt;/strong&gt; is the set of guarantees that make this work:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Atomicity&lt;/strong&gt; — All or nothing. If Step 2 fails, Step 1 is also undone. I tested this by putting &lt;code&gt;SELECT 1/0&lt;/code&gt; (division by zero error) in the middle of a transaction. The whole thing rolled back.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consistency&lt;/strong&gt; — The database only moves from one valid state to another. I set &lt;code&gt;CHECK (balance &amp;gt;= 0)&lt;/code&gt; on the accounts table. When I tried to deduct 5000 from an account with 1000, PostgreSQL threw an error and refused the operation completely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Isolation&lt;/strong&gt; — Uncommitted transactions are invisible to other connections. I opened two terminal windows to test this. Terminal 1 started a transaction and updated a balance but did not COMMIT. Terminal 2 could still see the old balance. Only after Terminal 1 ran &lt;code&gt;COMMIT&lt;/code&gt; did Terminal 2 see the new number.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Durability&lt;/strong&gt; — Once committed, data is permanent. PostgreSQL writes committed transactions to the Write-Ahead Log (WAL) on disk before returning a success response. Even if the server crashes one second after the COMMIT, the data will be there when the server restarts.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;All the code from this blog is in my GitHub repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/abdulrdeveloper/Learn-SQL&quot;&gt;github.com/abdulrdeveloper/Learn-SQL&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;You can find more of my work at &lt;a rel=&quot;nofollow&quot; href=&quot;https://abdulrdeveloper.me&quot;&gt;abdulrdeveloper.me&lt;/a&gt;&lt;br&gt;
Read more posts at &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.abdulrdeveloper.me&quot;&gt;blog.abdulrdeveloper.me&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21811/chai-aur-sql-a-beginners-journey-into-databases</guid>
<pubDate>Fri, 03 Jul 2026 04:56:02 +0000</pubDate>
</item>
<item>
<title>When Your Database Is On Fire at 2 AM, Ellie Shows Up</title>
<link>https://coderlegion.com/20660/when-your-database-is-on-fire-at-2-am-ellie-shows-up</link>
<description>&lt;p&gt;Most database problems don't announce themselves in advance. They show up at 2 AM, when systems are down and the clock is running. And the DBA who knew this environment best left the company six months ago.&lt;/p&gt;
&lt;p&gt;That's the reality Dave Page, CTO of pgEdge, has been watching play out for nearly 30 years in the Postgres community. It's also the problem he set out to fix with the pgEdge AI DBA Workbench, which moved to general availability last week.&lt;/p&gt;
&lt;h3 id=&quot;the-staffing-problem-is-the-database-problem&quot;&gt;The Staffing Problem Is the Database Problem&lt;/h3&gt;
&lt;p&gt;Ask most database teams where their biggest pain point is and you might expect the answer to be query performance, schema design, or index bloat. Page has a different take.&lt;/p&gt;
&lt;p&gt;&quot;Honestly, in my experience, it's finding staff that are experienced enough,&quot; he said.&lt;/p&gt;
&lt;p&gt;That's the context behind the Workbench. Teams are being asked to manage more Postgres infrastructure with fewer qualified people. And the tools they've had available were built for a simpler era — one where you set manual thresholds and hoped you caught problems before users did.&lt;/p&gt;
&lt;p&gt;The Workbench gives teams Ellie, an AI agent built directly into the monitoring system. She doesn't replace your DBA. But she works 24/7, and when something goes wrong, she can investigate 10 to 100 times faster than a human working through the problem manually.&lt;/p&gt;
&lt;p&gt;&quot;When your system's down and you're losing a million bucks an hour, Ellie helps you reduce that time,&quot; Page said.&lt;/p&gt;
&lt;h3 id=&quot;what-ellie-actually-sees&quot;&gt;What Ellie Actually Sees&lt;/h3&gt;
&lt;p&gt;The Workbench collects continuous snapshots of Postgres system views and stores them historically. If you have extensions like pg_stat_statements installed, Ellie has access to that data too — aggregate query stats across databases and users. Add the System Stats extension and she can also see operating system-level data: memory usage, CPU, disk mounts.&lt;/p&gt;
&lt;p&gt;On top of that, she has access to a live RAG database of current product documentation, so she isn't limited by whatever the underlying model's training cutoff happens to be.&lt;/p&gt;
&lt;p&gt;Every session is isolated to the individual user, so Ellie is working within your user context and permissions. Whatever Postgres exposes through system views for your session, she can see and query.&lt;/p&gt;
&lt;p&gt;The result is an agent with genuine situational awareness — not a chatbot taking guesses based on a prompt, but something that can actually see the current state of your systems and correlate it with historical data.&lt;/p&gt;
&lt;h3 id=&quot;three-tier-alerting-anomaly-detection-included&quot;&gt;Three-Tier Alerting, Anomaly Detection Included&lt;/h3&gt;
&lt;p&gt;One of the things Page said he always wanted to build but never had the technology for was anomaly-based alerting. With the Workbench, you don't need to manually configure thresholds for every metric. The system learns baselines and tells you when something is off.&lt;/p&gt;
&lt;p&gt;&quot;You don't need to do anything, just let it run,&quot; Page said.&lt;/p&gt;
&lt;p&gt;The alerting runs across three tiers, with the final tier being an LLM that assesses cluster state and flags what needs attention. Every chart and graph in the system has a button that invokes an AI session to explain what you're looking at and suggest next steps.&lt;/p&gt;
&lt;p&gt;Early on, the two most common issues the system has been surfacing are poorly indexed tables — showing up as queries running far longer than expected — and unexpectedly low cache hit ratios. Page noted the cache hit issue caught him off guard in frequency.&lt;/p&gt;
&lt;p&gt;&quot;Even on machines where you think, 'I've got plenty of memory on this box, it's going to be fine' — you look at the cache hit ratio and it's 8%,&quot; he said.&lt;/p&gt;
&lt;p&gt;When Ellie identifies a fixable problem, she generates the SQL. You can execute it directly from the UI or copy it and run it yourself through your own change management process.&lt;/p&gt;
&lt;h3 id=&quot;built-for-governance-not-just-speed&quot;&gt;Built for Governance, Not Just Speed&lt;/h3&gt;
&lt;p&gt;One of the questions that comes up immediately with any AI agent touching production databases is: what are the guardrails?&lt;/p&gt;
&lt;p&gt;The Workbench has an extensive RBAC system governing access not just to the system but to specific MCP tools and resources. LLM interactions are fully logged and traceable, which Page noted was built early for their own debugging purposes before becoming a governance feature.&lt;/p&gt;
&lt;p&gt;And the system is explicit about human approval for any destructive action.&lt;/p&gt;
&lt;p&gt;&quot;We don't want Claude running amok,&quot; Page said.&lt;/p&gt;
&lt;p&gt;The AI model layer is also flexible. The Workbench supports Anthropic, OpenAI, Voyage, Ollama, or any OpenAI API-compatible service. During the beta, the most common feedback was around proxy support — customers who route requests through a bearer-token proxy rather than connecting directly to an AI provider. That's now supported.&lt;/p&gt;
&lt;h3 id=&quot;free-to-use-open-source-at-the-core&quot;&gt;Free to Use, Open Source at the Core&lt;/h3&gt;
&lt;p&gt;The Workbench is available as a free download from GitHub and works with any Postgres environment running version 14 or above. pgEdge describes itself as a fiercely open-source company. The commercial play is straightforward: when larger teams are running the Workbench in production across hundreds of servers, they want a support contract and someone to call when something goes wrong.&lt;/p&gt;
&lt;p&gt;Page has been building graphical management tools for Postgres since the early days of the community. The Workbench, he said, is the monitoring system he always wanted to build — finally made possible by technology that didn't exist until recently.&lt;/p&gt;
&lt;p&gt;&quot;It's all the lessons learned, things I've wanted to do for the past 20 years,&quot; he said.&lt;/p&gt;
&lt;p&gt;Ben Fried, former CIO of Google and now a venture partner at Rally Ventures, put it more directly after seeing it: &quot;I wish I'd had something like this years ago.&quot;&lt;/p&gt;
&lt;p&gt;The Workbench is available at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pgEdge/ai-dba-workbench&quot;&gt;github.com/pgEdge/ai-dba-workbench&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20660/when-your-database-is-on-fire-at-2-am-ellie-shows-up</guid>
<pubDate>Tue, 16 Jun 2026 00:57:09 +0000</pubDate>
</item>
<item>
<title>Building LifeFast: A Solo Founder's Deep Dive into Fasting App Architecture</title>
<link>https://coderlegion.com/19608/building-lifefast-a-solo-founders-deep-dive-into-fasting-app-architecture</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Building LifeFast: A Solo Founder's Deep Dive into Fasting App Architecture&lt;br&gt;
published: true&lt;/p&gt;
&lt;h3 id=&quot;tags-reactnative-postgres-expo-healthapp&quot;&gt;tags: reactnative, postgres, expo, healthapp&lt;/h3&gt;
&lt;p&gt;There's a specific kind of frustration that comes from using a health app that feels like it was built by engineers who've never actually used it.&lt;/p&gt;
&lt;p&gt;The timer shows a number. The number counts down. You earn a badge. That's it.&lt;/p&gt;
&lt;p&gt;No context about what's happening inside your body. No sense of progression. No genuine reason to keep going beyond the guilt of breaking a streak. Just a countdown and a badge that means nothing by noon.&lt;/p&gt;
&lt;p&gt;That frustration is what made me build &lt;a rel=&quot;nofollow&quot; href=&quot;https://lifefast.online&quot;&gt;LifeFast&lt;/a&gt; — a fasting tracker that treats users like intelligent adults who deserve to understand their own biology.&lt;/p&gt;
&lt;p&gt;This is the story of how I built it. The technical decisions, the real challenges, the things I got completely wrong, and what I'd do differently. If you're building a health app, a mobile product, or just a solo-founder side project trying to become something real — I hope this saves you some time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-stack-and-why-i-made-these-choices-&quot;&gt;The Stack (And Why I Made These Choices)&lt;/h3&gt;
&lt;p&gt;Let me get the boring part out of the way first, because the choices here shaped everything downstream.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Frontend:&lt;/strong&gt; React Native via Expo&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backend:&lt;/strong&gt; Node.js + Express + TypeScript&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database:&lt;/strong&gt; PostgreSQL&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;State management:&lt;/strong&gt; Redux Toolkit + RTK Query&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build:&lt;/strong&gt; EAS Build (Expo Application Services)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Email:&lt;/strong&gt; Resend&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Payments:&lt;/strong&gt; Google Play Billing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push notifications:&lt;/strong&gt; Expo Push + custom job queue&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I picked React Native over Flutter primarily because I'm a JavaScript developer at heart and the ecosystem felt more familiar. Expo specifically was the right call for a solo founder — the managed workflow gets you from idea to TestFlight equivalent without spending a week configuring native build chains. The EAS Build system in particular is genuinely excellent.&lt;/p&gt;
&lt;p&gt;PostgreSQL was a non-negotiable. I've seen enough &quot;just use Firestore&quot; projects hit a wall the moment they needed anything resembling a JOIN, and for a health app with users, fasts, weights, water intake, community posts, and notification jobs all needing to relate to each other — a relational database was the only sane choice.&lt;/p&gt;
&lt;p&gt;RTK Query for data fetching was one of the better decisions I made early. The automatic caching, invalidation, and optimistic updates it provides out of the box took a huge amount of complexity off the table on the client side. Pairing it with AsyncStorage for persistence meant the app works properly offline without any heroic effort.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-hardest-part-modelling-time&quot;&gt;The Hardest Part: Modelling Time&lt;/h3&gt;
&lt;p&gt;Fasting is fundamentally about time. But time in software is famously treacherous — especially when your users are in 40+ different timezones.&lt;/p&gt;
&lt;p&gt;The core data model for a fast looks simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE fasts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED',
  timezone VARCHAR(100) NOT NULL,
  start_at TIMESTAMPTZ NOT NULL,
  end_at TIMESTAMPTZ NOT NULL,
  actual_start_at TIMESTAMPTZ,
  actual_end_at TIMESTAMPTZ,
  duration_target_mins INT NOT NULL,
  duration_actual_mins INT,
  ...
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trap I nearly fell into: storing times as local timestamps without timezone. I'd seen this mistake in other codebases and nearly made it myself anyway. &lt;code&gt;TIMESTAMPTZ&lt;/code&gt; stores everything as UTC internally and lets PostgreSQL handle timezone conversion correctly. Combined with storing the user's timezone string (&lt;code&gt;&quot;Africa/Nairobi&quot;&lt;/code&gt;, &lt;code&gt;&quot;America/New_York&quot;&lt;/code&gt;, etc.) — this meant every query, every calculation, every scheduled notification fired at the right local time.&lt;/p&gt;
&lt;p&gt;The server runs a cron job every 60 seconds:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;setInterval(async () =&amp;gt; {
  if (cronRunning) return;
  cronRunning = true;
  try {
    await autoStartDueFasts();
    await processDue();
  } catch (e: any) {
    console.error(&quot;[cron] tick error:&quot;, e?.message ?? e);
  } finally {
    cronRunning = false;
  }
}, 60_000);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;autoStartDueFasts()&lt;/code&gt; transitions &lt;code&gt;SCHEDULED&lt;/code&gt; fasts to &lt;code&gt;ACTIVE&lt;/code&gt; when their &lt;code&gt;start_at&lt;/code&gt; passes. &lt;code&gt;processDue()&lt;/code&gt; fires push notifications from a job queue. The guard (&lt;code&gt;if (cronRunning) return&lt;/code&gt;) prevents overlap on slow ticks — simple, and it works.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-feature-that-changed-everything-metabolic-stages&quot;&gt;The Feature That Changed Everything: Metabolic Stages&lt;/h3&gt;
&lt;p&gt;This is where LifeFast became a different kind of fasting app.&lt;/p&gt;
&lt;p&gt;Most fasting timers show you a percentage. 43%. 68%. 100%. These numbers don't mean anything experientially. What actually happens to your body during a fast follows a predictable, fascinating sequence — and if you show users that instead of just a countdown, everything changes.&lt;/p&gt;
&lt;p&gt;I defined fasting stages based on published metabolic research:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Stage &lt;/th&gt;&lt;th&gt;Hours &lt;/th&gt;&lt;th&gt;What's Happening&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Fed State &lt;/td&gt;&lt;td&gt;0–3h &lt;/td&gt;&lt;td&gt;Digesting, insulin elevated&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Early Fast &lt;/td&gt;&lt;td&gt;3–8h &lt;/td&gt;&lt;td&gt;Glycogen depleting, fat burning begins&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fasting State &lt;/td&gt;&lt;td&gt;8–12h &lt;/td&gt;&lt;td&gt;Insulin low, fat oxidation accelerating&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fat Burning &lt;/td&gt;&lt;td&gt;12–18h &lt;/td&gt;&lt;td&gt;Liver glycogen depleted, ketone production starting&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ketosis &lt;/td&gt;&lt;td&gt;18–24h &lt;/td&gt;&lt;td&gt;Ketones measurable, metabolic switch active&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Autophagy &lt;/td&gt;&lt;td&gt;24h+ &lt;/td&gt;&lt;td&gt;Cellular self-cleaning in full swing&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each stage has a name, a description, a colour, and a start/end threshold. On the client, I compute which stage the user is in at render time based on elapsed hours:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;function getFastingStage(elapsedHours: number): FastingStage {
  const stages = [
    { label: &quot;Fed State&quot;, color: &quot;#94a3b8&quot;, minHours: 0, maxHours: 3 },
    { label: &quot;Early Fast&quot;, color: &quot;#38bdf8&quot;, minHours: 3, maxHours: 8 },
    { label: &quot;Fat Burning&quot;, color: &quot;#fb923c&quot;, minHours: 8, maxHours: 18 },
    { label: &quot;Ketosis&quot;, color: &quot;#a78bfa&quot;, minHours: 18, maxHours: 24 },
    { label: &quot;Autophagy&quot;, color: &quot;#34d399&quot;, minHours: 24, maxHours: Infinity },
  ];
  return stages.findLast((s) =&amp;gt; elapsedHours &amp;gt;= s.minHours) ?? stages[0];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside the app's circular timer, the active stage label shows in its colour — a glowing cyan or violet or green dot next to the stage name. Users started messaging me: &lt;em&gt;&quot;I just hit Ketosis for the first time!&quot;&lt;/em&gt; — not because I told them to care, but because seeing it named and coloured in real time made it real.&lt;/p&gt;
&lt;p&gt;That's the difference between showing someone a number and showing them a story about their own biology.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;building-the-community-layer&quot;&gt;Building the Community Layer&lt;/h3&gt;
&lt;p&gt;About two months in, I realised I was building a productivity tool when I should have been building a social one. Fasting is hard in isolation. It's dramatically easier when you're doing it alongside people who understand the challenge.&lt;/p&gt;
&lt;p&gt;So I built a full community layer: groups, posts, comments, nested replies, likes, and a moderation system.&lt;/p&gt;
&lt;p&gt;The schema is relatively conventional, but a few decisions were non-obvious.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Soft deletes for moderation.&lt;/strong&gt; When a user reports a post and an admin hides it, the content isn't deleted — it's flagged:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ALTER TABLE community_posts
  ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
  ADD COLUMN hidden_reason TEXT,
  ADD COLUMN hidden_by UUID REFERENCES users(id),
  ADD COLUMN hidden_at TIMESTAMPTZ;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means we can review decisions, reverse them, and maintain an audit trail. Hard deletes felt wrong for a moderation workflow.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pinned posts per group.&lt;/strong&gt; The query for listing posts handles pinning cleanly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ORDER BY p.is_pinned DESC, p.created_at DESC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two-token sort. Pin always wins; recency breaks ties. Simple and fast.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The notification fan-out problem.&lt;/strong&gt; When a post in a popular group gets a comment, who gets notified? The author only. When a comment gets a reply, the parent comment's author gets notified. I built a lightweight &lt;code&gt;notification_jobs&lt;/code&gt; table and a worker that processes it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE notification_jobs (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users(id),
  kind VARCHAR(50) NOT NULL,
  scheduled_for TIMESTAMPTZ NOT NULL,
  payload JSONB,
  status VARCHAR(20) DEFAULT 'pending',
  attempts INT DEFAULT 0
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The worker polls every 60 seconds, picks up pending jobs past their &lt;code&gt;scheduled_for&lt;/code&gt; time, calls Expo's push notification API, and marks them as sent or failed. Dead simple, fully auditable, no external queue infrastructure needed at this scale.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-billing-nightmare&quot;&gt;The Billing Nightmare&lt;/h3&gt;
&lt;p&gt;I'm going to be honest: Google Play Billing is the worst API I have ever worked with professionally.&lt;/p&gt;
&lt;p&gt;The documentation is spread across three different Google sites that contradict each other. The test environment behaves differently from production in ways that are not documented. The error codes are numerical and the mapping table is buried six pages deep in a section titled &quot;Deprecated APIs&quot;.&lt;/p&gt;
&lt;p&gt;Here's what eventually worked:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Use the &lt;code&gt;google-play-billing&lt;/code&gt; library on the React Native side for purchase flow&lt;/li&gt;
&lt;li&gt;On the backend, verify every purchase token against the Google Play Developer API before granting entitlements&lt;/li&gt;
&lt;li&gt;Store the raw &lt;code&gt;purchase_token&lt;/code&gt; and &lt;code&gt;product_id&lt;/code&gt; — you'll need both for future verification and refund handling&lt;/li&gt;
&lt;li&gt;Handle &lt;code&gt;ITEM_ALREADY_OWNED&lt;/code&gt; (error code 7) gracefully — it means the user purchased on another account or device and is more common than you'd think&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The verification endpoint on my backend:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;router.post(&quot;/billing/google/verify&quot;, authenticate, async (req, res) =&amp;gt; {
  const { purchaseToken, productId, packageName } = req.body;

  const verified = await verifyGooglePurchase(packageName, productId, purchaseToken);
  if (!verified) return res.status(400).json({ success: false, error: &quot;Invalid purchase&quot; });

  const entitlement = getEntitlementForProduct(productId);
  await billingRepo.recordPurchase({ userId: req.user.id, purchaseToken, productId, entitlement });
  await usersRepo.grantEntitlement(req.user.id, entitlement);

  res.json({ success: true, entitlement });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The lesson: never grant entitlements on the client side. Always verify on the server. This is not optional.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;shipping-eas-build-and-deployment&quot;&gt;Shipping: EAS Build and Deployment&lt;/h3&gt;
&lt;p&gt;The CI/CD pipeline runs on GitHub Actions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Push to &lt;code&gt;main&lt;/code&gt; triggers the workflow&lt;/li&gt;
&lt;li&gt;TypeScript compiles, then the Express app is bundled&lt;/li&gt;
&lt;li&gt;SSH into the production DigitalOcean Droplet&lt;/li&gt;
&lt;li&gt;Pull latest, rebuild Docker container, swap with zero-downtime restart&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For the mobile app, EAS Build handles the actual Android build:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;eas build --platform android --profile production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then submit to Google Play via:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;eas submit --platform android
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;EAS Submit directly uploads the AAB to the Play Store internal track. For a solo founder, this is a genuinely transformative improvement over the old &quot;manually drag an AAB file into the Play Console&quot; workflow.&lt;/p&gt;
&lt;p&gt;One lesson I'd pass on: set up your production and staging environments early. I shipped several breaking changes to all users before I separated the two, and the user experience was awful. A staging environment you actually test on is not optional — it's the difference between professionalism and chaos.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-got-wrong&quot;&gt;What I Got Wrong&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Email broadcast timing.&lt;/strong&gt; I sent an announcement to my users about a new release. I wrote the batch-sending code to fire 50 emails in parallel. I had about 250 users at the time. The email API rate limits at 5 requests per second. You can do the math. Half my users got error responses instead of emails, and the 300ms sequential delay I needed was sitting there in the documentation the whole time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Over-engineering notifications early.&lt;/strong&gt; I spent a week building a sophisticated notification scheduling system before I had 50 users. The simpler version — a cron job and a jobs table — does the same job. I could have built it in a day and shipped the features users actually needed instead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Not adding the progress percentage cap sooner.&lt;/strong&gt; The fasting percentage can technically exceed 100% if someone continues fasting past their target window. For a while, my timer showed &quot;127%&quot; in a progress circle — which is both mathematically accurate and completely absurd. A one-line fix:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const percentage = Math.min(100, Math.max(0, Math.round(progressRatio * 100)));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Users notice things like this. They form impressions of quality from small details. Fix the small things before they become the big things.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-product-is-alive&quot;&gt;The Product Is Alive&lt;/h3&gt;
&lt;p&gt;LifeFast is available now on Android.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.jane.ndirangu.lifefast&quot;&gt;Download on Google Play&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://lifefast.online&quot;&gt;Website: lifefast.online&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It's free. It tracks your fasting window in real time, shows you your metabolic stage as you progress, logs your water intake, tracks your weight over time, lets you log progress photos, and connects you with a community of other fasters doing the same work.&lt;/p&gt;
&lt;p&gt;I built this without a team, without investors, and without a roadmap handed to me by a product manager. Just a problem I cared about, a stack I understood, and enough stubbornness to keep shipping.&lt;/p&gt;
&lt;p&gt;If you're building something similar — a health app, a behaviour-change product, anything where timing and user psychology overlap — feel free to reach out. I'd genuinely enjoy the conversation.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19608/building-lifefast-a-solo-founders-deep-dive-into-fasting-app-architecture</guid>
<pubDate>Wed, 03 Jun 2026 19:31:21 +0000</pubDate>
</item>
<item>
<title>Full Sales Data Analysis: From Raw Data to Interactive Business Dashboard</title>
<link>https://coderlegion.com/18144/full-sales-data-analysis-from-raw-data-to-interactive-business-dashboard</link>
<description>&lt;h3 id=&quot;introduction-2&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Anybody can be a data analyst,but what separates a good data analyst from a great one is what you can do and present.In this article i will be giving step by step guidelines from turning a raw data to an interactive business dashboards and giving insights.We will generate our sales data from Mockaroo,use Python to turn the JSON file to CSV,analyse with Sql and visualize with Power Bi.&lt;/p&gt;
&lt;h3 id=&quot;1-generating-the-sales-data-&quot;&gt;1.Generating the sales data.&lt;/h3&gt;
&lt;p&gt;You will go to your browser and search Mockaroo.com.Once open,hit the generate data button and give AI a command to generate the data for you.You can select the number of rows that you want your data to have.Once its done,save the data as a json file.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n07jaspo8ybkvi7nvy7i.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;a-viewing-the-data&quot;&gt;a.Viewing the data&lt;/h4&gt;
&lt;p&gt;Once your data is generated and saved in your files in your machine,go to your Github account and add that file to your repo.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7w5lqw6p7czm8p0dmls1.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
Once you open that file,hit the raw button and you will see something like this:&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ep9ymur5pcci2cqo0hdl.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;2-importing-using-python-&quot;&gt;2.Importing using Python.&lt;/h3&gt;
&lt;p&gt;The work is not yet done.Copy the link from your raw data in github because we shall need it.Use the import requests and the url will be your link you copied from raw data in github.While pasting the url,ensure it has the either the double or single quotes at the start and at the end of the url link.The status code must always be 200.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cnbtbzvntn1a6i9y4o8i.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;The next step will be seeing the type of data that we have,either a list or dictionary.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4aer1n8ky85m1txtg4m5.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
We have created a data frame,viewed the first rows and saved the the data as csv.&lt;br&gt;
You should be seing the sales data as an excel file in your files ready for analyzing.&lt;/p&gt;
&lt;h3 id=&quot;sql-analysis&quot;&gt;SQL analysis&lt;/h3&gt;
&lt;p&gt;Since we already have our data as CSV,we will import it in any sql environment you use.I will be using DBeaver.First you create a schema called sales_data or any name of your choice,then creating the sales table.&lt;/p&gt;
&lt;h4 id=&quot;1-creating-the-table&quot;&gt;1.creating the table&lt;/h4&gt;
&lt;p&gt;You create a schema called sales_data or any name of your choice,then creating the sales table.Every column name will be kept as texts.Why,to avoid data type errors during import and to handle missing values,weird formats or nulls with ease.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ayerdlg4gbnavbvh54th.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;2-importing-the-data-to-sql-environment&quot;&gt;2.Importing the data to SQL environment&lt;/h4&gt;
&lt;p&gt;first,you go to your schema,refresh and search for your table name.Right click it and hit the import data button.Import the csv then press next,hit the browse button to select which file exactly that you want,then press next till you proceed to the next part.After importing the data,hit the refresh button on the table name below your schema to ensure the data is set.You can now write a query to see your data&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o06nli0w3zjpu5kvl5cq.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;b-cleaning-the-data&quot;&gt;b.cleaning the data&lt;/h3&gt;
&lt;p&gt;This is where most data analyst spend most of their time.With reference to my sales data,it's not so messy only few mistakes like fixing the total_price column.&lt;/p&gt;
&lt;h4 id=&quot;1-fixing-the-total-price-column&quot;&gt;1.fixing the total_price column&lt;/h4&gt;
&lt;p&gt;As i imported my data,the column total_price was full of error &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vty0jx4rus4es7c91t2x.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
I will fix it by step by step as shown below.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1ov29v7zu6mstvt29q8e.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;This SQL command fixes the &lt;code&gt;total_price&lt;/code&gt; column by converting the text-based &lt;code&gt;quantity&lt;/code&gt; values to integers using &lt;code&gt;::INTEGER&lt;/code&gt;,then multiplying each by a flat rate of $19.99.The &lt;code&gt;WHERE&lt;/code&gt; clause targets only rows where &lt;code&gt;total_price&lt;/code&gt; still contains the error message,leaving already-corrected rows untouched.&lt;/p&gt;
&lt;h4 id=&quot;2-trimming-capitalizing-and-removing-special-characters&quot;&gt;2.Trimming,capitalizing and removing special characters&lt;/h4&gt;
&lt;p&gt;Triming in SQL eliminates spaces before and after any word.Initcap is a special command that allows you to standardise the letters of words inside your columns.This is a similar command to 'proper' as used in My SQL and other SQL environment.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m27t0o8ug7c8uht39g9i.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Now that our data is clean,we proceed to the next step which is answering business questions.Our data was not so messy so we used little times as possible.&lt;/p&gt;
&lt;h3 id=&quot;c-re-creating-the-clean-table-&quot;&gt;c.Re-creating the clean table.&lt;/h3&gt;
&lt;p&gt;Since our data was stored as texts format,before analysis we should ensure everything is in its correct format as we did in cleaning.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5kd110fl7pkf0js092ml.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
Some of our columns are not in their corect format.We can change them manually by writing a simple query by altering the table.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eq64nu1m18x4rhcbyy8l.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;d-answering-business-questions&quot;&gt;d.Answering business questions&lt;/h3&gt;
&lt;p&gt;This part is so important since we shall use it in visualization.Answering business questions like:sales performance,product analysis,customer insights and operational questions&lt;br&gt;
1.Finding top 10 sales rep&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8yh8t9zqe30u2m1ix3sj.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
2.finding which method of payments produces more revenue&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6qnf8g954o22qcla6epc.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;You can answer a lot of business questions as possible.I shall leave a link to my github for you to go through the business questions and answers.&lt;/p&gt;
&lt;h3 id=&quot;creating-views&quot;&gt;Creating views&lt;/h3&gt;
&lt;p&gt;Views in SQL simplifies your work as a HR or a CEO since you don't have to go through every long queries.We shall also be using the views created in Power BI in creating visuals.We will be creating payment method analysis view,products perfromance views,sales perfomance analysis view and sales summary analysis view as our facttable.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/phu2r9bdh6gahg0wj5zt.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
The query worked well and now we have the sales view.&lt;br&gt;
We have also created the sales rep perfomance view successfully.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5b4dsgisqls3h0aeczsk.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;The product perfomance views is also created successfully:&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uf03hll6532r5plzng7g.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;A payment method analysis view is also important we should have it as one of our views created:&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rn9e4pk7juu9debsqrr4.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Lastly,we must track down our customers so i created another view called customer analysis view and was succesfull&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9st0he00b6o0ikn2oj6i.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;3linking-our-sql-environment-to-power-bi&quot;&gt;3Linking our sql environment to Power BI&lt;/h3&gt;
&lt;p&gt;since we have our views created,we shall be using them in Power BI.&lt;br&gt;
1.Open Power BI and tap the home page,hit the import data from sql server then fill the required item:&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d15fu17iq840ucyqos7a.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
2.Once you have already linked,select only the views we created in sql earlier as in our case.It will appear as shown:&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wdew78cafbo55hybtle0.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;4-creating-relationships-&quot;&gt;4.Creating relationships.&lt;/h3&gt;
&lt;p&gt;Since we have 5 different mini-tables,we will create one called a fact table that will have a link to all the other tables.We will add a new column in each of the 5 different table called 'ID' column in relation with the name example 'product_id,sales_id'.After adding the new columns,we copy and paste them inside the fact table.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/65e8sa27tl2ybul3nkh2.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;After adding the new column having the ID,we will merge queries them together to the fact table.Joining the common columns.&lt;/p&gt;
&lt;h4 id=&quot;note-you-only-select-the-common-column-left-outer-join-&quot;&gt;NOTE:You only select the common column(left outer join).&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dczkw519xhfkccpt6ftv.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;5-creating-dashboards&quot;&gt;5.Creating dashboards&lt;/h3&gt;
&lt;p&gt;Once you have all the relationships,you can present your data in the forms of charts or any visual of choice.&lt;br&gt;
This below is one of the examples i came up with:&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q0tff766hf1i9nkx156y.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
You can have up to 5 maximum pages of different charts &lt;/p&gt;
&lt;h3 id=&quot;6-report-and-insights&quot;&gt;6.Report and Insights&lt;/h3&gt;
&lt;p&gt;Open a new page called report and insights and select a text box since we shall be using texts a lot more than charts.&lt;br&gt;
You can create a page called recommendation,executive report and so on.This can be presented to your boss with ease:&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4zrr7rnnzbcvm13xky6a.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;conclusion-2&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;That one right there is a full data analyst project we did.We used tools like Python to import the data,SQL for analysis and creating visuals using Power BI.You can add it to your portfolio as one of the Projects and can help you land a job with ease since you have proof.&lt;br&gt;
You can check more analysis i made using SQL :&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/JaysonJob/full-sales-data-analysis/blob/main/Script-35.sql&quot;&gt;https://github.com/JaysonJob/full-sales-data-analysis/blob/main/Script-35.sql&lt;/a&gt;&lt;br&gt;
I hope you found this article useful.Ciao&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18144/full-sales-data-analysis-from-raw-data-to-interactive-business-dashboard</guid>
<pubDate>Sun, 17 May 2026 08:03:49 +0000</pubDate>
</item>
<item>
<title>The Heart of B3: Building the Matching Engine with RabbitMQ, Redis and Spring Boot</title>
<link>https://coderlegion.com/17341/the-heart-of-b3-building-the-matching-engine-with-rabbitmq-redis-and-spring-boot</link>
<description>&lt;p&gt;Hello everyone!&lt;/p&gt;
&lt;p&gt;Continuing the &lt;strong&gt;My Broker B3&lt;/strong&gt; series, we've reached one of the most anticipated components of the ecosystem: the &lt;strong&gt;B3 Matching Engine API&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In the previous post, we built &lt;code&gt;b3-market-sync-api&lt;/code&gt; which synchronizes real prices from brapi.dev and stores them in Redis. Now it's time to use that data — this is the service that simulates the stock exchange itself, deciding whether a buy or sell order will be &lt;strong&gt;executed or rejected&lt;/strong&gt; based on the prices we just put in cache.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;-what-is-the-matching-engine-&quot;&gt;️ What is the Matching Engine?&lt;/h2&gt;
&lt;p&gt;In the real world, when you place a buy order through your broker, it gets sent to B3, which checks whether there's a counterpart willing to trade at that price. This process is called &lt;strong&gt;matching&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In our simulator, the &lt;code&gt;b3-matching-engine-api&lt;/code&gt; reproduces this logic in a simplified way:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Intake&lt;/strong&gt;: Receives the order from the broker via RabbitMQ (queue &lt;code&gt;mq-broker-to-b3&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Price&lt;/strong&gt;: Fetches the current asset price from Redis (&lt;code&gt;market:price:{TICKER}&lt;/code&gt;) — data injected by the Market Sync we built previously&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decision&lt;/strong&gt;: Applies the matching rule&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Persistence&lt;/strong&gt;: Records the result in PostgreSQL&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Notification&lt;/strong&gt;: Returns the result to the broker via RabbitMQ (queue &lt;code&gt;mq-b3-to-broker&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;[Broker] ──RabbitMQ──▶ [mq-broker-to-b3] ──▶ [Matching Engine]
                                                      │
                                             Redis: market:price:{TICKER}
                                             (fed by Market Sync ⬆️)
                                                      │
                                         ┌────────────┴────────────┐
                                      FILLED                   REJECTED
                                         │                         │
                                   PostgreSQL                 PostgreSQL
                                         │                         │
                               [mq-b3-to-broker] ◀────────────────┘
                                         │
                                      [Broker]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;mvp-focus&quot;&gt;MVP Focus&lt;/h2&gt;
&lt;p&gt;Before diving into the code, same disclaimer as previous posts: &lt;strong&gt;we're building the foundation&lt;/strong&gt;. The goal is to have an end-to-end flow working with enough robustness to validate the POC.&lt;/p&gt;
&lt;p&gt;In this phase, I prioritized:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;End-to-end flow working correctly&lt;/li&gt;
&lt;li&gt;Proper failure handling (no orders silently disappearing)&lt;/li&gt;
&lt;li&gt;Dead Letter Queue for messages that fail during processing&lt;/li&gt;
&lt;li&gt;REST API for querying execution history&lt;/li&gt;
&lt;li&gt;Swagger documentation&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;-tech-stack&quot;&gt;️ Tech Stack&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Technology &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Usage&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;Java 21&lt;/strong&gt; + &lt;strong&gt;Spring Boot 3.5&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Service core&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Spring RabbitMQ&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Order intake and result notification&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Spring Data Redis&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Real-time price lookup&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Spring Data JPA&lt;/strong&gt; + &lt;strong&gt;PostgreSQL&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Execution persistence&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Flyway&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Database schema versioning&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;SpringDoc OpenAPI&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Swagger UI documentation&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;-implementation-pillars&quot;&gt;️ Implementation Pillars&lt;/h2&gt;
&lt;h3 id=&quot;1-rabbitmq-configuration&quot;&gt;1. RabbitMQ Configuration&lt;/h3&gt;
&lt;p&gt;A critical lesson learned here: &lt;strong&gt;never rely on queues already existing in the broker&lt;/strong&gt;. If the application starts before RabbitMQ has the queues created, the consumer fails on startup.&lt;/p&gt;
&lt;p&gt;The solution is to declare all infrastructure beans directly in Spring — it ensures queues, exchanges and bindings exist before any message is processed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
public DirectExchange exchange() {
    return new DirectExchange(exchangeName);
}

@Bean
public Queue queueIn() {
    return QueueBuilder.durable(queueIn)
            .withArgument(&quot;x-dead-letter-exchange&quot;, dlxName)
            .withArgument(&quot;x-dead-letter-routing-key&quot;, dlqName)
            .build();
}

@Bean
public Binding bindingQueueIn(Queue queueIn, DirectExchange exchange) {
    return BindingBuilder.bind(queueIn).to(exchange).with(routingKey);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the &lt;code&gt;withArgument&lt;/code&gt; calls — they configure the &lt;strong&gt;Dead Letter Queue&lt;/strong&gt; directly in the main queue declaration. Any message that fails processing is automatically redirected.&lt;/p&gt;
&lt;h3 id=&quot;2-the-matching-logic&quot;&gt;2. The Matching Logic&lt;/h3&gt;
&lt;p&gt;The heart of the service. The rule is straightforward:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;BUY&lt;/strong&gt;: If the price the user accepts to pay &amp;gt;= market price → &lt;code&gt;FILLED&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SELL&lt;/strong&gt;: If the price the user wants to receive &amp;lt;= market price → &lt;code&gt;FILLED&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Otherwise → &lt;code&gt;REJECTED&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private static boolean isCanExecute(OrderEventDTO order, BigDecimal marketPrice) {
    if (SideStatus.BUY.name().equalsIgnoreCase(order.getSide())) {
        return order.getPrice().compareTo(marketPrice) &amp;gt;= 0;
    } else if (SideStatus.SELL.name().equalsIgnoreCase(order.getSide())) {
        return order.getPrice().compareTo(marketPrice) &amp;lt;= 0;
    }
    return false;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;3-failure-handling-no-silent-order-loss&quot;&gt;3. Failure Handling — No Silent Order Loss&lt;/h3&gt;
&lt;p&gt;A silent bug I identified: when the ticker wasn't in Redis, the method simply returned without doing anything. The broker would wait for a response that never came.&lt;/p&gt;
&lt;p&gt;The fix was simple but critical — any failure must notify the broker:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;if (marketDataOpt.isEmpty()) {
    log.warn(&quot;Price for ticker {} not found in Redis. Rejecting order {}&quot;,
             order.getTicker(), order.getOrderId());
    OrderResponseEvent response = new OrderResponseEvent(
        order.getOrderId(), ExecutionStatus.REJECTED.name(), BigDecimal.ZERO);
    orderProducer.sendToBroker(response);
    return;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt; This is exactly why we built &lt;code&gt;b3-market-sync-api&lt;/code&gt; first. If Redis doesn't have the price, the order is immediately rejected — correct and predictable behavior.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;4-transactional-guarantee&quot;&gt;4. Transactional Guarantee&lt;/h3&gt;
&lt;p&gt;Another critical point: persisting to PostgreSQL and publishing to RabbitMQ are two distinct operations. If the database saves but the publish fails, the execution is recorded but the broker is never notified — silent inconsistency.&lt;/p&gt;
&lt;p&gt;The solution was adding &lt;code&gt;@Transactional&lt;/code&gt; to the method that orchestrates both operations:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
private void saveAndNotify(OrderEventDTO order, BigDecimal price, ExecutionStatus status) {
    // 1. Persists in PostgreSQL
    OrderExecution execution = OrderExecution.builder()
            .orderId(order.getOrderId())
            .ticker(order.getTicker())
            .side(SideStatus.valueOf(order.getSide()))
            .quantity(order.getQuantity())
            .executedPrice(price)
            .status(status)
            .build();
    repository.save(execution);

    // 2. Notifies the Broker
    OrderResponseEvent response = new OrderResponseEvent(
        order.getOrderId(), status.name(), price);
    orderProducer.sendToBroker(response);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;5-dead-letter-queue&quot;&gt;5. Dead Letter Queue&lt;/h3&gt;
&lt;p&gt;For messages that fail in the consumer (unexpected processing error), we configured a full DLQ setup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
public DirectExchange deadLetterExchange() {
    return new DirectExchange(dlxName);
}

@Bean
public Queue deadLetterQueue() {
    return QueueBuilder.durable(dlqName).build();
}

@Bean
public Binding bindingDlq(Queue deadLetterQueue, DirectExchange deadLetterExchange) {
    return BindingBuilder.bind(deadLetterQueue)
            .to(deadLetterExchange)
            .with(dlqName);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the consumer, we rethrow the exception so RabbitMQ knows the message failed and routes it to the DLQ:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RabbitListener(queues = &quot;${app.rabbitmq.queue-in}&quot;)
public void receiveOrder(OrderEventDTO event) {
    log.info(&quot;New order received: ID {} | Ticker {} | Side {}&quot;,
            event.getOrderId(), event.getTicker(), event.getSide());
    try {
        matchingService.process(event);
    } catch (Exception e) {
        log.error(&quot;Failed to process order {}: {}&quot;, event.getOrderId(), e.getMessage(), e);
        throw e; // RabbitMQ routes to DLQ
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;6-rest-api-swagger&quot;&gt;6. REST API + Swagger&lt;/h3&gt;
&lt;p&gt;Since the service exposes query endpoints, I configured &lt;strong&gt;Swagger UI&lt;/strong&gt; from the start:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
public OpenAPI customOpenAPI() {
    return new OpenAPI()
            .info(new Info()
                    .title(&quot;B3 Matching Engine API&quot;)
                    .version(&quot;1.0.0&quot;)
                    .description(&quot;Simulates the B3 stock exchange matching engine...&quot;));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Available endpoints:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Method &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Endpoint &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Description&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;GET &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;/api/v1/executions&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;List all executions&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;GET &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;/api/v1/executions/order/{orderId}&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Find by order ID&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;GET &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;/api/v1/executions/ticker/{ticker}&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Find by ticker&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;GET &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;/api/v1/executions/status/{status}&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Find by status&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;-validating-the-execution&quot;&gt;✅ Validating the Execution&lt;/h2&gt;
&lt;p&gt;With the application running locally, all components started correctly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ Flyway applied the &lt;code&gt;order_executions&lt;/code&gt; table migration&lt;/li&gt;
&lt;li&gt;✅ Hibernate validated the schema&lt;/li&gt;
&lt;li&gt;✅ RabbitMQ connected with queues and DLQ declared&lt;/li&gt;
&lt;li&gt;✅ Tomcat running on port &lt;code&gt;8091&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;✅ Swagger UI accessible at &lt;code&gt;http://localhost:8091/swagger-ui.html&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-s-next-&quot;&gt;What's Next?&lt;/h2&gt;
&lt;p&gt;With &lt;code&gt;b3-market-sync-api&lt;/code&gt; feeding Redis and &lt;code&gt;b3-matching-engine-api&lt;/code&gt; ready to process orders, the entire B3 side is operational.&lt;/p&gt;
&lt;p&gt;The next step is building the &lt;strong&gt;broker-order-api&lt;/strong&gt; — the orchestrator on the broker side that will manage the complete order lifecycle:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Receive the buy/sell intent from the user&lt;/li&gt;
&lt;li&gt;Validate the ticker via REST against &lt;code&gt;broker-asset-api&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Save the order as &lt;code&gt;PENDING&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Publish the event to Kafka&lt;/li&gt;
&lt;li&gt;Send the order to the Matching Engine via RabbitMQ&lt;/li&gt;
&lt;li&gt;Consume the feedback and update the final status&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once that service is ready, we'll have the first complete end-to-end flow running in the ecosystem.&lt;/p&gt;
&lt;p&gt;Got any questions about the matching logic or RabbitMQ DLQ configuration? Drop them in the comments!&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;about-the-series&quot;&gt;About the Series&lt;/h2&gt;
&lt;p&gt;⬅️ Previous Post: &lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;Syncing the Real Market: Consuming Brapi and Feeding Redis with Spring Boot&lt;/a&gt;&lt;/p&gt;
&lt;p&gt; Series Index: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/rvneto/series-roadmap-building-a-stock-brokerage-simulator-with-microservices-kgh&quot;&gt;Series Roadmap&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Links:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/roberto-de-vargas/&quot;&gt;LinkedIn&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/rvneto/trading-b3-matching-engine&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17341/the-heart-of-b3-building-the-matching-engine-with-rabbitmq-redis-and-spring-boot</guid>
<pubDate>Sun, 10 May 2026 21:29:07 +0000</pubDate>
</item>
<item>
<title>Understanding Logic, Reusability and Integrity On SQL ; Procedures, Functions and Transactions.</title>
<link>https://coderlegion.com/17301/understanding-logic-reusability-and-integrity-sql-procedures-functions-transactions</link>
<description>&lt;p&gt;SQL is widely known for data querying and manipulation but systems do grow; data becomes larger; processes become repetitive and operations become sensitive. SQL has some features which enables it to be considered a fully fledged programming language. Some of the features which I discuss in this article are procedures, functions and transactions. Each of these concepts serve distinct purposes.&lt;br&gt;
Procedures execute operations, functions return values, and transactions ensure those operations are safe.&lt;br&gt;
&lt;/p&gt;&lt;h2&gt;Stored Procedures&lt;/h2&gt;&lt;br&gt;
These are set of SQL statements stored in the database and executed as a unit which are used to perform tasks such as &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; etc.&lt;br&gt;
They are triggered by calling them and passing the expected parameters by the procedure.&lt;br&gt;
Here is an example of a procedure:
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE OR REPLACE PROCEDURE increase_salary(p_dept TEXT, p_percent NUMERIC)
LANGUAGE plpgsql
AS \(
BEGIN
    UPDATE employees
    SET salary = salary + (salary * p_percent / 100)
    WHERE department = p_dept;
END;
\);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is how a procedure is called:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CALL increase_salary('IT', 10);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Functions&lt;/h2&gt;
&lt;p&gt;This is a reusable logic block which can return values and can be used inside queries for data selection. &lt;br&gt;
It can be used with &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;WHERE&lt;/code&gt; and is great for reusability.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE OR REPLACE FUNCTION get_avg_salary(p_dept TEXT)
RETURNS NUMERIC
LANGUAGE plpgsql
AS \(
BEGIN
    RETURN (
        SELECT AVG(salary)
        FROM employees
        WHERE department = p_dept
    );
END;
\);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is an example of how the function can be used:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT name, salary
FROM employees
WHERE salary &amp;gt; get_avg_salary(department);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Transactions&lt;/h2&gt;
&lt;p&gt;Transactions are used to group a set of code operations in which if any of it fails then the whole execution is aborted. This ensures that data is only changed when the full code execution structure is successful.&lt;br&gt;
Transactions are best for data safety as they prevent partial updates.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

UPDATE accounts
SET balance = balance + 100
WHERE id = 2;

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ROLLBACK;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How They Work Together&lt;/h2&gt;
&lt;p&gt;It will not be complete to conclude the article without showing an example of how these concepts can be used together. &lt;br&gt;
Here is a simple scenario showing the use of the three concepts interdependently.&lt;br&gt;
&lt;b&gt;Function:&lt;/b&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE OR REPLACE FUNCTION get_balance(acc_id INT)
RETURNS NUMERIC AS \(
BEGIN
    RETURN (SELECT balance FROM accounts WHERE id = acc_id);
END;
\) LANGUAGE plpgsql;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;b&gt;Procedure:&lt;/b&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE OR REPLACE PROCEDURE transfer_money(from_id INT, to_id INT, amount NUMERIC)
LANGUAGE plpgsql
AS $$
BEGIN
    IF get_balance(from_id) &amp;lt; amount THEN
        RAISE EXCEPTION 'Insufficient funds';
    END IF;

    UPDATE accounts SET balance = balance - amount WHERE id = from_id;
    UPDATE accounts SET balance = balance + amount WHERE id = to_id;
END;
$$;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;b&gt;Transaction:&lt;/b&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;
CALL transfer_money(1, 2, 100);
COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;SQL has a wide range of capabilities more than just being a querying language. Having features such as procedures, functions, transactions and many others helps it to be an efficient tool for use on data in whichever way needed. Mastering it comes a long way in helping the analysis of data directly from the database.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17301/understanding-logic-reusability-and-integrity-sql-procedures-functions-transactions</guid>
<pubDate>Sun, 10 May 2026 16:23:47 +0000</pubDate>
</item>
<item>
<title>From Aiven to Railway: Our Database Migration</title>
<link>https://coderlegion.com/16741/from-aiven-to-railway-our-database-migration</link>
<description>&lt;h3 id=&quot;i-didn-t-plan-a-migration-i-just-noticed-we-were-paying-for-two-databases-&quot;&gt;I didn't plan a migration. I just noticed we were paying for two databases.&lt;/h3&gt;
&lt;p&gt;This whole thing started with a very unglamorous realization:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;We were paying for Aiven Postgres…&lt;br&gt;
and also paying for Railway.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At some point I opened the Railway dashboard properly and thought:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Wait — why aren't we using this for the database too?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;No incident. No outage. No scaling crisis.&lt;br&gt;
Just mild financial confusion.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-decision-wasn-t-really-should-we-migrate-&quot;&gt;The decision wasn't really &quot;should we migrate?&quot;&lt;/h3&gt;
&lt;p&gt;It was more like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;We're already using Railway for everything else. Why is the database emotionally living somewhere else?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So I did what any developer does when something feels inconsistent: I turned it into a &lt;em&gt;migration project&lt;/em&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-migration-itself-was-weirdly-easy&quot;&gt;The migration itself was weirdly easy&lt;/h3&gt;
&lt;p&gt;I expected:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;scripts&lt;/li&gt;
&lt;li&gt;downtime planning&lt;/li&gt;
&lt;li&gt;CSV exports&lt;/li&gt;
&lt;li&gt;at least one regretful late-night rollback&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead, Railway just pointed me to their Postgres migrator:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://railway.com/deploy/postgres-migrator&quot;&gt;Railway Postgres Migrator&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The setup was:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Source:&lt;/strong&gt; Aiven Postgres URL&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Destination:&lt;/strong&gt; Railway Postgres&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Action:&lt;/strong&gt; Deploy&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's it. I didn't trust it at first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-one-thing-that-actually-tripped-me-up&quot;&gt;The one thing that actually tripped me up&lt;/h3&gt;
&lt;p&gt;Before the migrator worked, I hit a version mismatch. Railway's Postgres was running v18 — and the migrator requires source and destination to be on the same major version. Aiven was v17, so I had to upgrade it first before anything else would proceed.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7ryfmti4tzp0ftd9ye4o.png&quot; alt=&quot;A screenshot of a PostgreSQL migration log showing timestamped entries from April 27, 2026. It records validation steps for source and target databases, indicating the source is PostgreSQL 17.9 and the target is PostgreSQL 18.3. The final line displays an error message stating “connection validation failed: major version mismatch,” highlighting that both databases must use the same major version.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Once versions matched, there was another quiet gotcha: two optional environment variables — &lt;code&gt;NO_ACL&lt;/code&gt; and &lt;code&gt;NO_OWNER&lt;/code&gt; — had to be set to &lt;code&gt;true&lt;/code&gt; for the migration to complete cleanly.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Variable &lt;/th&gt;&lt;th&gt;What it does&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NO_ACL&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Skips restoring access privileges (GRANT/REVOKE commands)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NO_OWNER&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Skips restoring object ownership (ALTER OWNER commands)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The Railway docs show their default as &lt;code&gt;true&lt;/code&gt;, but in practice they were &lt;code&gt;false&lt;/code&gt; — meaning the migrator was trying to restore ACLs and ownership from Aiven, hitting permission errors, and failing silently enough that it wasn't immediately obvious why.&lt;/p&gt;
&lt;p&gt;Setting both to &lt;code&gt;true&lt;/code&gt; explicitly is what actually made it work.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;i-kept-waiting-for-something-to-break-nothing-did-&quot;&gt;I kept waiting for something to break. Nothing did.&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;No data loss.  &lt;/li&gt;
&lt;li&gt;No partial sync weirdness.  &lt;/li&gt;
&lt;li&gt;No &quot;why is production dead&quot; moment.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Just a working database in a new place, which is almost more stressful than failure — because you keep checking if you missed something.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;railway-vs-aiven-honest-experience-not-a-benchmark-&quot;&gt;Railway vs Aiven (honest experience, not a benchmark)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Aiven Postgres:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Solid and reliable&lt;/li&gt;
&lt;li&gt;More manual setup&lt;/li&gt;
&lt;li&gt;External to your deployment ecosystem&lt;/li&gt;
&lt;li&gt;SSL and config overhead you have to manage&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Railway Postgres:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lives inside your deployment environment&lt;/li&gt;
&lt;li&gt;Internal networking by default&lt;/li&gt;
&lt;li&gt;Less cognitive overhead on config&lt;/li&gt;
&lt;li&gt;Everything in one dashboard&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The biggest difference wasn't performance. It was &lt;strong&gt;mental overhead&lt;/strong&gt;. Fewer places to check. Fewer credentials to rotate. One less tab permanently open.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;would-i-recommend-this-&quot;&gt;Would I recommend this?&lt;/h3&gt;
&lt;p&gt;If you're already on Railway and running an external Postgres for no strong reason — probably yes. The migrator genuinely works, the internal networking is a real quality-of-life upgrade, and the consolidation is worth it just to stop context-switching between dashboards.&lt;/p&gt;
&lt;p&gt;If you have a strong reason for keeping the database external (compliance, specific Aiven features, multi-cloud setups) — that calculus changes. But &quot;we started there and never questioned it&quot; isn't a strong reason.&lt;/p&gt;
&lt;p&gt;Sometimes the migration is just noticing you forgot to consolidate.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16741/from-aiven-to-railway-our-database-migration</guid>
<pubDate>Tue, 05 May 2026 09:40:39 +0000</pubDate>
</item>
<item>
<title>How to Connect Power BI to a SQL (PostgreSQL) Database and Build a Unified Dashboard</title>
<link>https://coderlegion.com/16637/how-to-connect-power-bi-to-a-sql-postgresql-database-and-build-a-unified-dashboard</link>
<description>&lt;h3 id=&quot;introduction-3&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Power BI is a business intelligence (BI) and data visualization tool from Microsoft. It enables analysts and business users to transform raw data into interactive dashboards and reports. Companies use Power BI to analyze sales, customer behavior, inventory trends, and other critical business metrics.&lt;/p&gt;
&lt;p&gt;SQL databases, like PostgreSQL, are widely used to store and manage structured data. Connecting Power BI to SQL databases allows analysts to retrieve, clean, transform, and model data efficiently, creating a single source of truth for dashboards and KPIs.&lt;/p&gt;
&lt;p&gt;In this article, we’ll walk through:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Connecting Power BI to local and cloud PostgreSQL databases&lt;/li&gt;
&lt;li&gt;Loading raw data and applying transformations&lt;/li&gt;
&lt;li&gt;Combining datasets from multiple sources&lt;/li&gt;
&lt;li&gt;Adding a Date Dimension table (DimDate) for KPIs and filtering&lt;/li&gt;
&lt;li&gt;Preparing a dashboard-ready model for GitHub and reporting&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;what-you-need-before-connecting&quot;&gt;What You Need Before Connecting&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Power BI Desktop installed&lt;/li&gt;
&lt;li&gt;PostgreSQL database connection details (server, port, database, username, password) &lt;/li&gt;
&lt;li&gt;For cloud connections: SSL certificate (CA certificate)&lt;/li&gt;
&lt;li&gt;Model view showing relationships across cloud and local tables&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;connecting-to-a-local-postgresql-database&quot;&gt;Connecting to a Local PostgreSQL Database&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Open Power BI Desktop.&lt;/li&gt;
&lt;li&gt;Click &lt;code&gt;Get Data → PostgreSQL database&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/muja5c62us91l6vuhe3r.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/20klv8w166r4cc41iml0.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Enter your connection info:&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Server: &lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;localhost:5432 (replace 5432 with your PostgreSQL port)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Database: Name of your database&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mutt8pxcsxiq4dxb8ug2.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Enter your username and password.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z8u5wi3x3dhg6fzipr7w.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Click OK to connect.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;localhost:port &lt;/code&gt; syntax ensures Power BI connects to the correct PostgreSQL server port.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In the Navigator window, select tables to load, then click Load (or Transform Data).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qhaeme1626hoc82bzloi.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oighsih3wdlxiipqr02w.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;connecting-to-a-cloud-postgresql-database-aiven-or-similar-&quot;&gt;Connecting to a Cloud PostgreSQL Database (Aiven or Similar)&lt;/h3&gt;
&lt;p&gt;Cloud databases require secure SSL connections.&lt;/p&gt;
&lt;h4 id=&quot;guide-to-aiven-connection&quot;&gt;Guide to Aiven Connection&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;From your cloud provider, obtain:&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Host&lt;/li&gt;
&lt;li&gt;Port&lt;/li&gt;
&lt;li&gt;Database name&lt;/li&gt;
&lt;li&gt;Username and password&lt;/li&gt;
&lt;li&gt;SSL certificate (CA certificate)&lt;/li&gt;
&lt;li&gt;Install the CA certificate on your system:&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open the downloaded .crt or .pem file.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Install it in the trusted root certificate store:&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;Windows: Right-click → Install Certificate → Place in “Trusted Root Certification Authorities”
&lt;/code&gt;&lt;br&gt;
&lt;code&gt;Linux/macOS: Follow OS instructions to add to the system or user trust store&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Installing the CA certificate allows Power BI to validate the server’s identity and establish a secure connection.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;In Power BI Desktop:&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Go to &lt;code&gt;Get Data → PostgreSQL database&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Enter Server&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;your-cloud-host:5432
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; and Database&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i8woqdjcyo7gbuqy2j30.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Check Use SSL certificate and select the installed certificate&lt;/li&gt;
&lt;li&gt;Enter your username and password&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zw3s255xopek538qzdyz.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If successful, it opens your aiven cloud database as shown below&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/njvy6d7xhlcn0hudte0r.png&quot; alt=&quot;Image description&quot;&gt;&lt;/li&gt;
&lt;li&gt;Choose tables from your database&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e3406ibiobq3temr53iy.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Click OK and load the tables&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;load-raw-data-first&quot;&gt;Load Raw Data First&lt;/h5&gt;
&lt;p&gt;It’s important to load raw data before applying transformations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Load all tables (customers, products, sales, inventory) first.&lt;/li&gt;
&lt;li&gt;Review the raw data to assess missing values, incorrect or inconsistent formats and unnecessary columns&lt;/li&gt;
&lt;li&gt;This helps you understand which fields need cleaning and transformation before building dashboards.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;filtering-and-transformation&quot;&gt;Filtering and Transformation&lt;/h3&gt;
&lt;p&gt;Use Power BI’s Power Query Editor for data preparation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Remove unnecessary columns – keep only fields needed for analysis.&lt;/li&gt;
&lt;li&gt;Rename columns – make names descriptive.&lt;/li&gt;
&lt;li&gt;Filter rows – e.g., remove canceled sales or test records.&lt;/li&gt;
&lt;li&gt;Change data types – ensure numeric and date fields are correct.&lt;/li&gt;
&lt;li&gt;Create calculated columns – e.g., total sales = quantity × price.&lt;/li&gt;
&lt;li&gt;Handle missing values – fill, replace, or remove nulls.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;creating-relationships-between-tables&quot;&gt;Creating Relationships Between Tables&lt;/h3&gt;
&lt;p&gt;Once data is cleaned:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Switch to Model View.&lt;/li&gt;
&lt;li&gt;Create relationships:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;`sales.customer_id → customers.customer_id&lt;/p&gt;
&lt;p&gt;sales.product_id → products.product_id&lt;/p&gt;
&lt;p&gt;inventory.product_id → products.product_id`&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oz1v03qw42h4pz9g8cfh.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
Validate relationships for correct cardinality and cross-filtering.&lt;/p&gt;
&lt;p&gt;Relationships ensure Power BI can accurately summarize data across multiple tables.&lt;/p&gt;
&lt;h3 id=&quot;data-modeling-basics&quot;&gt;Data Modeling Basics&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Star schema&lt;br&gt;
 Fact table (sales) connected to dimension tables (customers, products, inventory).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cardinality&lt;br&gt;
One-to-many or many-to-one relationships.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Filter directions&lt;br&gt;
 Define single or bidirectional filters depending on your analysis needs.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Proper modeling ensures dashboards are interactive and accurate.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8s7yqhb8frrf01gcvxyj.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-sql-skills-matter-for-power-bi-analysts&quot;&gt;Why SQL Skills Matter for Power BI Analysts&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Data extraction: Retrieve exactly the data you need.&lt;/li&gt;
&lt;li&gt;Filtering &amp;amp; aggregation: Pre-process data for better performance.&lt;/li&gt;
&lt;li&gt;Data cleaning &amp;amp; transformation: Prepare data before loading it into Power BI.&lt;/li&gt;
&lt;li&gt;Performance optimization: Efficient queries improve dashboard responsiveness.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By combining SQL knowledge with Power BI, analysts can build powerful, reliable dashboards that drive business decisions.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-3&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Connecting PostgreSQL to Power BI empowers analysts to turn structured data into actionable insights. Whether using local or cloud databases, establishing secure and reliable connections ensures that the data you analyze is accurate and up-to-date. By first loading raw data, applying necessary transformations, and creating relationships, including a Date table for time-based analysis, you build a robust data model ready for interactive dashboards.&lt;/p&gt;
&lt;p&gt;Mastering this workflow not only simplifies KPI calculations and trend analysis but also highlights the importance of SQL skills for preparing and managing data. Ultimately, integrating PostgreSQL with Power BI provides a seamless bridge between data storage and visualization, enabling smarter, data-driven business decisions.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16637/how-to-connect-power-bi-to-a-sql-postgresql-database-and-build-a-unified-dashboard</guid>
<pubDate>Mon, 04 May 2026 13:29:57 +0000</pubDate>
</item>
<item>
<title>Sub-queries vs Window Functions vs Common Table Expressions: Which Should You Use in SQL?</title>
<link>https://coderlegion.com/16078/sub-queries-vs-window-functions-vs-common-table-expressions-which-should-you-use-in-sql</link>
<description>&lt;p&gt;SQL  is the most used tool for data manipulation but what happens when there are some concepts that seem to work towards the same ultimate outputs. As queries become more complex, developers often encounter different techniques that appear to produce similar results—namely sub-queries, common table expressions (CTEs), and window functions.&lt;br&gt;
At first glance, these concepts can seem interchangeable. You might solve the same problem using any of them, which raises a natural question: &lt;i&gt;which one is better?&lt;/i&gt;&lt;br&gt;
&lt;strong&gt;The goal is not to choose one, but to understand when each is the right tool.&lt;/strong&gt;&lt;br&gt;
In this article I will discuss about sub-queries, window functions and common table expressions;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sub-Queries&lt;/strong&gt;&lt;br&gt;
These are queries inside other queries and are executed either before or alongside the outer query. They can return scalar value, row or table.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT *
FROM employees e
WHERE salary = (
    SELECT MAX(salary)
    FROM employees
    WHERE department = e.department
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Window functions&lt;/strong&gt;&lt;br&gt;
These are used when you are trying to preserve your rows. Used together with components such as - &lt;code&gt;OVER()&lt;/code&gt;,&lt;code&gt;PARTITION BY&lt;/code&gt;,&lt;code&gt;ORDER BY&lt;/code&gt; and common functions such as - &lt;code&gt;SUM()&lt;/code&gt;,&lt;code&gt;AVG()&lt;/code&gt;,&lt;code&gt;RANK()&lt;/code&gt;,&lt;code&gt;LAG()&lt;/code&gt;,&lt;code&gt;LEAD()&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT employee_id,first_name, last_name,salary, job_title, department
FROM (
  SELECT *,
    RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS rnk
  FROM employees
) t
WHERE rnk = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Common Table Expressions (CTEs)&lt;/strong&gt;&lt;br&gt;
These are temporary results set that are initialized using -&lt;code&gt;WITH&lt;/code&gt;. They are often used to improve readability and modularity of scripts.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH max_salary AS (
    SELECT department, MAX(salary) AS max_sal
    FROM employees
    GROUP BY department
)
SELECT e.*
FROM employees e
JOIN max_salary m
    ON e.department = m.department
WHERE e.salary = m.max_sal;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All these features of SQL are used to perform advanced data querying and transformations. They can all be tweaked to perform almost similar queries but it all depends on what the final output is expected to be.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When to use what&lt;/strong&gt;&lt;br&gt;
Each of the features has an instance when it is the most convenient over the others. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For instance when it is a simple filtering such as retaining all those above average marks then a sub-query is the best option.&lt;/li&gt;
&lt;li&gt;When we are more focused on row level analytics then the window functions come in handy for that task.&lt;/li&gt;
&lt;li&gt;In a situation where it is a multi-step logic and re-usability is a core factor the common table expressions are best suited for use.&lt;br&gt;
&lt;br&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Using in collaboration&lt;/strong&gt;&lt;br&gt;
The best thing about this features is that they can be used together for the best solution. This helps in the data transformation where there are steps within it in which either is considered to be superior over the other.&lt;br&gt;
Here is an example where all of them are used together:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;with ranked AS (
    SELECT 
        *,
        AVG(salary) OVER (PARTITION BY department) AS avg_salary
    FROM employees
)
SELECT *
FROM ranked
WHERE 
    salary &amp;gt; avg_salary
    AND salary &amp;gt; (
        SELECT AVG(salary) FROM employees
    );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Best Practices&lt;/strong&gt;&lt;br&gt;
These are some of the recommended practices when working with these SQL features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It is best to avoid deep nesting of queries by using CTEs which help in the structuring of the script for easy readability.&lt;/li&gt;
&lt;li&gt;When using CTEs it is necessary to name them well so that their purpose can be identified easily without having to read all the code snippets manually.&lt;/li&gt;
&lt;li&gt;Window functions are often more efficient than sub queries in large datasets, so where possible it is best to use window functions over sub queries but in instances of small datasets any can be used depending on preference.&lt;br&gt;
&lt;br&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;br&gt;
These are to be considered more as &lt;strong&gt;complementary tools&lt;/strong&gt; than competing tools. In most advanced queries you will have to use them together or interchangeably to get to the results. &lt;br&gt;
What makes a major difference is how you use them as they can heavily impact the performance. It is best to master all of them and understand the strength and weaknesses of each so that when it get to a point of picking one or more you do it from a point of information.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16078/sub-queries-vs-window-functions-vs-common-table-expressions-which-should-you-use-in-sql</guid>
<pubDate>Tue, 28 Apr 2026 16:33:58 +0000</pubDate>
</item>
<item>
<title>How I Survived My First Full stack App with Next.js</title>
<link>https://coderlegion.com/4131/how-i-survived-my-first-full-stack-app-with-next-js</link>
<description>&lt;p&gt;The story begins—as it always does—with a tutorial.&lt;/p&gt;
&lt;p&gt;I was watching yet another video and thought: &lt;em&gt;“Let’s build a full stack app.”&lt;/em&gt; I already had a background in React, and I’d made a few full stack projects before. So this time, I chose &lt;strong&gt;Next.js&lt;/strong&gt;. It looked easy, especially with the new App Router.&lt;/p&gt;
&lt;p&gt;Why Next.js? Because it’s React at the front, backend APIs included, and server actions baked right in. No need for separate Express servers or wiring up extra routers. Also, the assumption was simple: &lt;em&gt;everything is a server component unless you slap a &lt;code&gt;&quot;use client&quot;&lt;/code&gt; on top.&lt;/em&gt; I was in.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-why-i-fell-in-love-with-the-app-router&quot;&gt;✨ Why I Fell in Love with the App Router&lt;/h3&gt;
&lt;p&gt;One of the best things about Next.js is the App Router. You don’t need to install React Router and define routes in &lt;code&gt;App.jsx&lt;/code&gt;. Just drop a file in the &lt;code&gt;/app&lt;/code&gt; folder, and boom—it’s a route. It's simple. It’s fast. It works.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;first-battle-the-ui-fear&quot;&gt;First Battle: The UI Fear&lt;/h3&gt;
&lt;p&gt;Here’s my harsh truth—I suck at design.&lt;/p&gt;
&lt;p&gt;My pages looked &lt;em&gt;ugly&lt;/em&gt;. And that really messes with you. It triggers imposter syndrome. I’d open VS Code, freeze, and think: &lt;em&gt;What do I even build here?&lt;/em&gt; The spiral begins: “Am I even cut out for frontend dev?”&lt;/p&gt;
&lt;p&gt;Sound familiar? Been there. This time, I didn’t let it win.&lt;/p&gt;
&lt;h4 id=&quot;here-s-how-i-beat-the-block-&quot;&gt;Here's how I beat the block:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Took breaks&lt;/strong&gt; when stuck.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drew wireframes&lt;/strong&gt;, even if they were ugly. Just enough to give me direction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Used inspiration from other websites&lt;/strong&gt;. Seriously, 90% of the internet uses the same layout tricks. Why not learn from them?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tools like Google Stitch, v0.dev, and Bolt&lt;/strong&gt; helped generate page ideas and inspiration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Any progress is better than no progress. Remember that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-server-actions&quot;&gt;❤️ Server Actions&lt;/h3&gt;
&lt;p&gt;God bless Server Actions.&lt;/p&gt;
&lt;p&gt;No more creating an API route or writing fetch calls and handling them manually. Need to read from the server? Just keep the page component as a server component, use a Server Action to grab the data, and pass it down to your client components. Done.&lt;/p&gt;
&lt;p&gt;You can &lt;strong&gt;use server actions inside client components too&lt;/strong&gt;, but there are caveats. I won’t pretend I know them all—this project was me figuring things out on the fly. So I won’t preach, but here’s what worked for me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep data fetching in server components.&lt;/li&gt;
&lt;li&gt;Use server actions to mutate or fetch data.&lt;/li&gt;
&lt;li&gt;If using forms, yes—you can still call server actions from client components.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-i-wrote-ugly-code-fast&quot;&gt;How I Wrote Ugly Code—Fast&lt;/h3&gt;
&lt;p&gt;Not gonna lie. I was a messy developer in the beginning.&lt;/p&gt;
&lt;p&gt;Every time I made a new page/component, I didn't care about reusability. Things got chaotic fast.&lt;/p&gt;
&lt;p&gt;But I followed &lt;strong&gt;one rule&lt;/strong&gt;: put every component inside a well-named folder. For example: &lt;code&gt;/components/dashboard/Table.tsx&lt;/code&gt;. Later, when I needed it somewhere else, I refactored it—added props, defined interfaces, added type safety—and made it reusable.&lt;/p&gt;
&lt;p&gt;So... am I a great React developer? Maybe not.&lt;/p&gt;
&lt;p&gt;Am I resourceful and fast? Yes.&lt;/p&gt;
&lt;p&gt;Best case: smart developer.&lt;/p&gt;
&lt;p&gt;Worst case: functional chaos.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-email-saga&quot;&gt;The Email Saga&lt;/h3&gt;
&lt;p&gt;Now I understand why dev’s complain about &lt;strong&gt;bad DX (Developer Experience)&lt;/strong&gt; on Twitter.&lt;/p&gt;
&lt;p&gt;Until now, I had a chill life—&lt;code&gt;React + Vite&lt;/code&gt;, everything worked like magic. But for this project, I needed to &lt;strong&gt;send emails&lt;/strong&gt; inviting users to give feedback.&lt;/p&gt;
&lt;p&gt;I initially tried &lt;strong&gt;Mailjet&lt;/strong&gt; — looked promising, solid docs, decent limits. But then... &lt;strong&gt;everything broke&lt;/strong&gt; after sending just 5 emails.&lt;/p&gt;
&lt;p&gt;I got hit with a &lt;strong&gt;&quot;temporary restriction&quot;&lt;/strong&gt; and no real explanation. Just a vague message and complete silence. I waited &lt;strong&gt;three days&lt;/strong&gt;, hoping the ban would lift. Spoiler: it didn’t.&lt;/p&gt;
&lt;p&gt;That’s when I realized how important good &lt;strong&gt;developer experience&lt;/strong&gt; really is.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If I ever build a dev tool, I’m writing great docs, clear errors, and probably a fun YouTube guide.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So, I ditched Mailjet and moved to &lt;strong&gt;Resend&lt;/strong&gt; — and this time, everything &lt;em&gt;just worked&lt;/em&gt;. Fast setup, friendly DX, and no cryptic errors.&lt;/p&gt;
&lt;p&gt;❤️ Huge shoutout to Resend.&lt;/p&gt;
&lt;p&gt;???? No hard feelings, Mailjet — but that experience wasn’t it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-broken-database-horror&quot;&gt;The Broken Database Horror&lt;/h3&gt;
&lt;p&gt;After surviving all that, I wanted to &lt;strong&gt;brag on LinkedIn&lt;/strong&gt;: “Hey look! I integrated payments!” Even if they were test payments—still, it’s progress.&lt;/p&gt;
&lt;p&gt;But the horror unfolded quickly.&lt;/p&gt;
&lt;p&gt;Understanding how payments work end-to-end—from initiation to confirmation—is &lt;strong&gt;critical&lt;/strong&gt;. If you don’t, you’ll keep breaking your schema with every little change.&lt;/p&gt;
&lt;h4 id=&quot;what-i-learned-&quot;&gt;What I learned:&lt;/h4&gt;
&lt;p&gt;If you're dealing with &lt;strong&gt;third-party APIs or external systems&lt;/strong&gt;, always:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Track their &lt;strong&gt;status&lt;/strong&gt; (&lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;failure&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;enum&lt;/code&gt; fields to reflect the state&lt;/li&gt;
&lt;li&gt;Expect that some parts of the flow are &lt;strong&gt;out of your control&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Know what you're storing and when. Or prepare to suffer. And yes, it &lt;em&gt;will&lt;/em&gt; become a dev horror story.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;linting-typescript-woes&quot;&gt;Linting + TypeScript Woes&lt;/h3&gt;
&lt;p&gt;I used to think &lt;strong&gt;linting&lt;/strong&gt; was just that one VS Code extension that makes code pretty.&lt;/p&gt;
&lt;p&gt;Then I ran &lt;code&gt;npm run build&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Boom. &lt;strong&gt;10,000+ lines of errors.&lt;/strong&gt; Why? It tried to lint the auto-generated code too.&lt;/p&gt;
&lt;p&gt;So yeah, I had to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fix unused variables&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;any&lt;/code&gt; types&lt;/li&gt;
&lt;li&gt;Update configs to ignore generated folders&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Lesson learned: don't skip linting. TypeScript isn’t just for safety—it’s a build-saver.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;deployment-amp-production-nightmares&quot;&gt;Deployment &amp;amp; Production Nightmares&lt;/h3&gt;
&lt;p&gt;I’d spent a month building this project. Now it was time to ship. Resume-ready, you know?&lt;/p&gt;
&lt;p&gt;I used &lt;strong&gt;Vercel&lt;/strong&gt;. Guess what?&lt;/p&gt;
&lt;p&gt;Vercel &lt;strong&gt;kept trying to render server pages as static&lt;/strong&gt;. My build kept failing.&lt;/p&gt;
&lt;p&gt;Turns out: Vercel will &lt;em&gt;try to statically generate everything&lt;/em&gt; unless you tell it not to.&lt;/p&gt;
&lt;h4 id=&quot;solution-&quot;&gt;Solution:&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export const dynamic = &quot;force-dynamic&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Boom. Now it knows your page is meant to run &lt;strong&gt;server-side&lt;/strong&gt; only.&lt;/p&gt;
&lt;p&gt;Oh, and &lt;strong&gt;Supabase&lt;/strong&gt;—my free DB. Connecting it was hell.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;There’s a &lt;strong&gt;pooled connection&lt;/strong&gt; (for normal ops)&lt;/li&gt;
&lt;li&gt;And a &lt;strong&gt;direct connection&lt;/strong&gt; (for migrations)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But Prisma didn’t play well with pooled URLs. It caused weird, unexpected issues.&lt;/p&gt;
&lt;h4 id=&quot;fix-&quot;&gt;Fix:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;Supabase’s &quot;stable connection string&quot;&lt;/strong&gt; for local + migrations&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;Prisma Accelerate&lt;/strong&gt; in production with the correct URL&lt;/li&gt;
&lt;li&gt;Add the Accelerate URL to your &lt;code&gt;.env.production&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After that, things worked. Kinda.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;You may not have learned any &lt;em&gt;technical&lt;/em&gt; tricks from this post. But that’s the point.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Things will break.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Abstractions will leak.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You’ll be stuck. Frustrated. Confused.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;But you’re a builder. And builders show up.&lt;/p&gt;
&lt;p&gt;That’s what matters.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-takeaway&quot;&gt;❤️ Takeaway&lt;/h3&gt;
&lt;p&gt;Everyone can teach you code. I want to teach you a mindset.&lt;/p&gt;
&lt;p&gt;If you remember just two things from this post:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Break things.&lt;/strong&gt; That’s how you learn.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Draw wireframes.&lt;/strong&gt; Ugly ones. It gives you momentum.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s it. You’ll be 10x faster than me if you just start.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;follow-my-journey&quot;&gt;Follow My Journey&lt;/h3&gt;
&lt;p&gt;I write about my dev rants, lessons, and experiments—sometimes messy, sometimes insightful. If this resonated, follow me. I don’t just rant—I teach too.&lt;/p&gt;
&lt;p&gt;Next up: how I broke and optimized my backend.&lt;/p&gt;
&lt;p&gt;Till then,&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Break. Fix. Weep. Shine.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;— Love and peace,&lt;/p&gt;
&lt;p&gt;Your fellow dev&lt;/p&gt;
&lt;h3 id=&quot;farewell-amp-little-sendoff-gifts-for-curious-minds&quot;&gt;Farewell &amp;amp; Little Sendoff Gifts for Curious Minds&lt;/h3&gt;
&lt;p&gt;Thanks for sticking around! If you're curious to poke around or steal some ideas (I won't tell), here are a few goodies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Live App:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://feedback.swaroop101.me/&quot;&gt;Create Next App&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GitHub Repo:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/PRASHANTSWAROOP001/feedback-nextjs-app&quot;&gt;https://github.com/PRASHANTSWAROOP001/feedback-nextjs-app&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wireframes (Chaos Included):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://raw.githubusercontent.com/PRASHANTSWAROOP001/contact-page/refs/heads/main/wireframe2.jpg&quot;&gt;https://raw.githubusercontent.com/PRASHANTSWAROOP001/contact-page/refs/heads/main/wireframe2.jpg&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/PRASHANTSWAROOP001/contact-page/blob/main/wireframe1.jpg?raw=true&quot;&gt;https://github.com/PRASHANTSWAROOP001/contact-page/blob/main/wireframe1.jpg?raw=true&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>React</category>
<guid isPermaLink="true">https://coderlegion.com/4131/how-i-survived-my-first-full-stack-app-with-next-js</guid>
<pubDate>Fri, 18 Jul 2025 12:38:28 +0000</pubDate>
</item>
</channel>
</rss>