<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>TheCodeBuzz</title>
	<atom:link href="https://thecodebuzz.com/feed/" rel="self" type="application/rss+xml" />
	<link>https://thecodebuzz.com</link>
	<description>Best Practices for Software Development</description>
	<lastBuildDate>Sun, 25 Oct 2026 02:51:27 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://thecodebuzz.com/wp-content/uploads/2022/11/cropped-android-chrome-512x512-1-1-51x51.jpg</url>
	<title>TheCodeBuzz</title>
	<link>https://thecodebuzz.com</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Cassandra Best Practices: Data Modeling, Consistency, Operations</title>
		<link>https://thecodebuzz.com/cassandra-best-practices-guidelines/</link>
					<comments>https://thecodebuzz.com/cassandra-best-practices-guidelines/#respond</comments>
		
		<dc:creator><![CDATA[Izzy]]></dc:creator>
		<pubDate>Sun, 25 Oct 2026 02:51:21 +0000</pubDate>
				<category><![CDATA[Database Best Practices]]></category>
		<category><![CDATA[Cassandra]]></category>
		<category><![CDATA[Cassandra 5.0]]></category>
		<category><![CDATA[Data Modeling]]></category>
		<category><![CDATA[NoSQL]]></category>
		<guid isPermaLink="false">https://thecodebuzz.com/?p=30906</guid>

					<description><![CDATA[<p>Cassandra best practices for data modeling, consistency levels, repair, backups and upgrades, with rules and a production checklist for 5.0.</p>
<p>The post <a href="https://thecodebuzz.com/cassandra-best-practices-guidelines/">Cassandra Best Practices: Data Modeling, Consistency, Operations</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>Today in this article, we will see the Cassandra best practices that matter for a production cluster. We cover data modeling, consistency and day-2 operations, so you can design the cluster and also keep it healthy after go-live.</p>
<p>The scope includes query-first modeling, partition key design, Storage-Attached Indexes (SAI), consistency levels, lightweight transactions (LWT) and Accord, tombstones, compaction, drivers, security, repair, backups, monitoring and upgrades. Every rule comes with the reason behind it, so you can judge when it fits your workload and when it does not.</p>
<p>Many popular guides still carry pre-5.0 advice. This guide adds what they often miss: the limits of SAI, UCS versus the STCS default, repair tied to <code>gc_grace_seconds</code>, driver settings, security and restore testing. It is written for engineers, architects and DBAs who run Cassandra in production.</p>
<p>For the version baseline, Apache Cassandra 5.0 is the latest GA series (5.0.9, released 2026-08-07), and 4.1 is the previous stable line (4.1.12). The 3.x series is end-of-life. Cassandra 6.0 is still pre-GA (6.0-alpha3) and is not for production.</p>
<blockquote><p><strong>Version note:</strong> This guide targets Apache Cassandra 5.0.9 (latest GA) and 4.1.12 (previous stable line), and treats 6.0-alpha3 as pre-GA only.</p>
<p>&nbsp;</p>
<p><strong>In this guide</strong></p>
<p>&nbsp;</p></blockquote>
<ul>
<li><a href="#cassandra-best-practices-at-a-glance">Cassandra Best Practices at a Glance</a></li>
<li><a href="#cassandra-data-modeling-best-practices-and-partition-key-des">Cassandra Data Modeling Best Practices and Partition Key Design</a></li>
<li><a href="#indexing-in-cassandra-5-0-sai-secondary-indexes-or-more-tabl">Indexing in Cassandra 5.0: SAI, Secondary Indexes or More Tables</a></li>
<li><a href="#consistency-levels-and-replication-factor-choosing-local-quo">Consistency Levels and Replication Factor: Choosing LOCAL_QUORUM</a></li>
<li><a href="#transactions-and-lightweight-transactions-paxos-v2-and-accor">Transactions and Lightweight Transactions: Paxos v2 and Accord in Cassandra 6.0</a></li>
<li><a href="#tombstones-ttl-and-gc-grace-seconds-deleting-data-safely">Tombstones, TTL and gc_grace_seconds: Deleting Data Safely</a></li>
<li><a href="#choosing-a-compaction-strategy-stcs-lcs-twcs-or-ucs">Choosing a Compaction Strategy: STCS, LCS, TWCS or UCS</a></li>
<li><a href="#drivers-connection-management-and-security">Drivers, Connection Management and Security</a></li>
<li><a href="#repair-backup-and-restore-best-practices">Repair, Backup and Restore Best Practices</a></li>
<li><a href="#operations-monitoring-and-upgrades">Operations, Monitoring and Upgrades</a></li>
<li><a href="#common-cassandra-anti-patterns">Common Cassandra Anti-Patterns</a></li>
<li><a href="#cassandra-production-readiness-checklist">Cassandra Production Readiness Checklist</a></li>
<li><a href="#key-takeaways">Key takeaways</a></li>
<li><a href="#faq">FAQ</a></li>
</ul>
<h2 id="cassandra-best-practices-at-a-glance">Cassandra Best Practices at a Glance</h2>
<p>Apache Cassandra is a distributed, wide-column database built for high write throughput and availability across nodes and datacenters. Today in this guide, we will see Cassandra data modeling best practices, consistency, indexing, repair, backups and upgrades in one place.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;"><p><strong>Note:</strong> A partition is like one drawer in a filing cabinet. Every row with the same partition key goes into the same drawer, and that drawer lives on specific nodes. Unlike a real cabinet, each drawer is copied to several nodes, so one failure doesn&#8217;t lose it.</p></blockquote>
<h3>Best Practices Summary Table</h3>
<p>The table below lists the main areas, what to do and what to avoid. Each row is explained in its own section later.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Area</th>
<th>Do this</th>
<th>Avoid this</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Data modeling</strong></td>
<td>Model one table per query and denormalize.</td>
<td>Designing tables from entities and expecting joins.</td>
</tr>
<tr>
<td><strong>Partition key</strong></td>
<td>Use a high-cardinality key and keep partitions under about 100 MB and 100,000 items.</td>
<td>Low-cardinality keys (e.g. country, status) and unbounded partitions.</td>
</tr>
<tr>
<td><strong>Indexing</strong></td>
<td>Use extra tables for hot queries and SAI for secondary lookups.</td>
<td>Legacy secondary indexes in new designs.</td>
</tr>
<tr>
<td><strong>Consistency</strong></td>
<td>Use LOCAL_QUORUM for reads and writes in multi-datacenter clusters.</td>
<td>ALL, or cross-datacenter QUORUM by default.</td>
</tr>
<tr>
<td><strong>Replication factor</strong></td>
<td>Use RF=3 per datacenter with NetworkTopologyStrategy.</td>
<td>SimpleStrategy in multi-datacenter production.</td>
</tr>
<tr>
<td><strong>Transactions</strong></td>
<td>Use LWT only for compare-and-set on one partition.</td>
<td>LWT as the default write path.</td>
</tr>
<tr>
<td><strong>Deletes and TTL</strong></td>
<td>Prefer TTL and time bucketing over explicit deletes.</td>
<td>Queue-style tables and explicit null writes.</td>
</tr>
<tr>
<td><strong>Compaction</strong></td>
<td>Pick a strategy per table (UCS, or TWCS for TTL-heavy time series).</td>
<td>Leaving every table on the default without review.</td>
</tr>
<tr>
<td><strong>Drivers</strong></td>
<td>Use one long-lived session, prepared statements and an explicit local datacenter.</td>
<td>One session per request.</td>
</tr>
<tr>
<td><strong>Security</strong></td>
<td>Enable authentication, authorization and TLS, and replace the default superuser.</td>
<td>Running with defaults open.</td>
</tr>
<tr>
<td><strong>Repair</strong></td>
<td>Finish repair inside gc_grace_seconds (default 864000 seconds, 10 days).</td>
<td>Rare or skipped repair.</td>
</tr>
<tr>
<td><strong>Backups</strong></td>
<td>Schedule snapshots, store them off the node and test restores.</td>
<td>Snapshots that were never restored.</td>
</tr>
<tr>
<td><strong>Monitoring</strong></td>
<td>Watch partition size, pending compactions, latency and dropped messages.</td>
<td>Looking only at CPU and disk.</td>
</tr>
<tr>
<td><strong>Upgrades</strong></td>
<td>Move off 3.x because it is end-of-life, and roll one node at a time.</td>
<td>Staying on 3.x or running pre-GA builds.</td>
</tr>
</tbody>
</table>
</figure>
<h3>Which Cassandra Version Does This Guide Assume?</h3>
<p>This guide uses the versions below as its baseline.</p>
<ul>
<li><strong>5.0.9:</strong> the latest GA release, and the preferred target.</li>
<li><strong>4.1.12:</strong> the latest on the previous stable line.</li>
<li><strong>6.0-alpha3:</strong> pre-GA and not for production.</li>
<li><strong>Java:</strong> Java 11 and 17 are used with Cassandra 5.0.x.</li>
</ul>
<h2 id="cassandra-data-modeling-best-practices-and-partition-key-des">Cassandra Data Modeling Best Practices and Partition Key Design</h2>
<p><a href="https://thecodebuzz.com/what-is-data-modelling-fundamentals-sql-nosql-guidelines/">Design your tables from your queries</a>, not from your entities. A good Cassandra model has three goals: <strong>spread data evenly</strong> with a high-cardinality partition key, <strong>bound partition size</strong>, and <strong>read as few partitions as possible</strong>, ideally one per query.</p>
<h3>Why should a query read only one partition?</h3>
<p>A single-partition read goes to the replicas that own that partition. A multi-partition read makes the coordinator (the node handling the request) contact several nodes and wait for the slowest one. That adds latency variance, so the occasional slow response hurts your tail latency.</p>
<h3>How do you choose the partition key and clustering columns?</h3>
<p>The partition key decides which nodes hold the data. Clustering columns decide the sort order of rows inside that partition. Pick the partition key from the column your query always filters by with equality, and pick clustering columns from the sort or range you need.</p>
<p>Example: a retail app shows &#8220;my orders, newest first&#8221;. The table below shows how the choice maps.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Part</th>
<th>Column</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Partition key</strong></td>
<td><code>customer_id</code></td>
<td>High cardinality, and every query supplies it</td>
</tr>
<tr>
<td><strong>Clustering column</strong></td>
<td><code>order_date DESC</code></td>
<td>Newest orders are read first, with no sorting at query time</td>
</tr>
<tr>
<td><strong>Clustering column</strong></td>
<td><code>order_id</code></td>
<td>Keeps rows unique when two orders share a timestamp</td>
</tr>
</tbody>
</table>
</figure>
<h3>How big should a partition be?</h3>
<p>Keep partitions under about <strong>100 MB</strong> and under about <strong>100,000 items</strong>. The hard practical limit is 2 billion cells per partition, but you should never plan to get near it.</p>
<p>Oversized partitions hurt in several ways:</p>
<ul>
<li><strong>Heavy reads:</strong> the node must scan and merge more data for each request.</li>
<li><strong>Compaction and repair pressure:</strong> large partitions take longer to merge and to compare between replicas.</li>
<li><strong>Hot nodes:</strong> one partition lives on only a few replicas, so one big partition loads those nodes unevenly.</li>
</ul>
<h3>How does bucketing keep partitions bounded?</h3>
<p>Bucketing means adding a column, such as a day or hour, to the partition key so a busy key stops growing forever. One IoT sensor writing every second would otherwise build a single unbounded partition.</p>
<blockquote><p><strong>In plain English:</strong> Think of a ledger where you start a new page every day. You never end up with one endless page, and you can still find any day quickly. The catch is that a question spanning ten days means opening ten pages.</p></blockquote>
<p>Here is a simplified sensor table with a day bucket in the partition key, followed by a single-partition read:</p>
<p class="code-label"><strong>CQL</strong></p>
<pre><code class="language-sql">CREATE TABLE iot.sensor_readings (
    sensor_id    text,
    day_bucket   date,
    reading_time timestamp,
    temperature  double,
    PRIMARY KEY ((sensor_id, day_bucket), reading_time)
) WITH CLUSTERING ORDER BY (reading_time DESC);

SELECT reading_time, temperature
FROM iot.sensor_readings
WHERE sensor_id = 'sensor-1001'
  AND day_bucket = '2026-10-20'
LIMIT 100;
</code></pre>
<p>✅ One partition, newest readings first, and each partition holds at most one day of data per sensor.</p>
<p>The trade-off: a query over several days now touches several partitions. The application must list the buckets and query each one explicitly (e.g. seven queries for a week). Choose a bucket size so a partition stays within the limits above at your peak write rate.</p>
<h3>What are hot partitions and how do you avoid them?</h3>
<p>A hot partition is one that receives a disproportionate share of traffic. Low-cardinality keys such as <code>country</code> or <code>status</code> create them, because millions of rows share a few key values and land on a few nodes. Use a key with many distinct values, and add a bucket if one value is still very busy.</p>
<h3>Is denormalization normal in Cassandra?</h3>
<p>Yes. Cassandra has no joins, so you store the same data in several tables, one per query (e.g. <code>orders_by_customer</code> and <code>orders_by_date</code>). One write to several tables is cheaper than a read that must gather data from many partitions.</p>
<p>Use a logged batch only to keep those denormalized tables in sync, because it guarantees all writes eventually apply. Don&#8217;t use it for speed. A batch across many partitions adds work on the coordinator.</p>
<h3>Why avoid ALLOW FILTERING and full scans?</h3>
<p><code>ALLOW FILTERING</code> lets a query run without a full partition key, so Cassandra reads many partitions and discards rows that don&#8217;t match. Cost grows with table size, and latency becomes unpredictable. Keep it out of application paths. If you need the query often, create a table for it instead.</p>
<h2 id="indexing-in-cassandra-5-0-sai-secondary-indexes-or-more-tabl">Indexing in Cassandra 5.0: SAI, Secondary Indexes or More Tables</h2>
<p>Use <strong>denormalized tables</strong> for hot, latency-critical queries. Use <strong>Storage-Attached Indexes (SAI)</strong>, new in Cassandra 5.0, for secondary lookups where a partition-key query is not possible. Avoid legacy secondary indexes (2i) in new designs.</p>
<h3>What is SAI in Cassandra 5.0?</h3>
<p>SAI is an index attached to the SSTables of a table. It lets you filter on non-key columns without creating a separate table. The 5.0 feature list also includes a vector data type with similarity functions, which builds on SAI.</p>
<blockquote><p><strong>In plain English:</strong> A denormalized table is a second filing cabinet sorted the way you search. SAI is a small index card stapled to each existing drawer, so you can look things up by another field. The cards help, but you still have to know which drawers to open.</p></blockquote>
<h3>Denormalized table, SAI or legacy index?</h3>
<p>The table below compares the three options across cost and limits.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Option</th>
<th>Read cost</th>
<th>Write cost</th>
<th>Storage</th>
<th>Best fit</th>
<th>Limits</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Denormalized table</strong></td>
<td>Lowest, single partition</td>
<td>One extra write per table</td>
<td>Full copy of the data</td>
<td>Primary, latency-critical queries</td>
<td>App must keep copies in sync</td>
</tr>
<tr>
<td><strong>SAI</strong></td>
<td>Low with a partition restriction, variable without</td>
<td>Index update on write</td>
<td>Index files per SSTable</td>
<td>Secondary filters on non-key columns</td>
<td>Does not replace query-first modeling</td>
</tr>
<tr>
<td><strong>Legacy 2i</strong></td>
<td>Often high, can hit many nodes</td>
<td>Index update on write</td>
<td>Hidden local index table</td>
<td>Rarely a good choice</td>
<td>Poor with high-cardinality columns</td>
</tr>
</tbody>
</table>
</figure>
<h3>What are the limits of SAI?</h3>
<p>An SAI query with no partition key may fan out across nodes, so it adds latency variance. SAI does not remove the need for Cassandra data modeling best practices. Test every SAI query on production-size data before you rely on it, because small test sets hide fan-out cost.</p>
<h3>When should you pick which?</h3>
<ul>
<li><strong>Primary query:</strong> &#8220;orders by customer&#8221; is the main read path, so build it as a table keyed by customer.</li>
<li><strong>Secondary filter:</strong> &#8220;find orders by status in this customer&#8217;s partition&#8221; is fine with SAI plus a partition restriction (e.g. a retail support screen).</li>
<li><strong>Cross-partition search:</strong> Use SAI only if the latency variance is acceptable. Otherwise add another table.</li>
</ul>
<p>Here is a simplified example that indexes the <code>status</code> column and queries it inside one customer partition:</p>
<p class="code-label"><strong>CQL</strong></p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS shop.orders_by_customer (
  customer_id uuid,
  order_id timeuuid,
  status text,
  total decimal,
  PRIMARY KEY ((customer_id), order_id)
) WITH CLUSTERING ORDER BY (order_id DESC);

CREATE INDEX IF NOT EXISTS orders_status_sai
  ON shop.orders_by_customer (status) USING 'sai';

SELECT order_id, status, total
FROM shop.orders_by_customer
WHERE customer_id = 7b1f3c52-8a6e-4c1d-9d0e-3f2a5b6c7d88
  AND status = 'SHIPPED';</code></pre>
<p>The query returns only the shipped orders for that one customer. The partition key restriction keeps it on the replicas of a single partition, and SAI handles the <code>status</code> filter.</p>
<h2 id="consistency-levels-and-replication-factor-choosing-local-quo">Consistency Levels and Replication Factor: Choosing LOCAL_QUORUM</h2>
<p>For most multi-datacenter production workloads, use <strong>RF=3 per datacenter</strong> with <code>LOCAL_QUORUM</code> for both reads and writes. RF=3 lets a quorum form from two agreeing replicas, tolerates one node failure and allows rolling restarts without downtime.</p>
<h3>What is a consistency level?</h3>
<p>A consistency level is the number of replicas that must confirm a read or write before Cassandra reports success to the client. You choose it per request, so different queries can make different trade-offs.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;"><p><strong>Note :</strong> Three people hold a copy of the same record. A consistency level decides how many of them must say &#8220;done&#8221; before you accept the answer, like a committee that needs a majority vote. The analogy stops fitting in one place: Cassandra still sends the write to all copy-holders, it only waits for some of them.</p></blockquote>
<h3>How do QUORUM and LOCAL_QUORUM differ?</h3>
<p><code>QUORUM</code> needs RF/2 + 1 replicas (integer division) across <em>all</em> datacenters. <code>LOCAL_QUORUM</code> needs a majority only in the coordinator&#8217;s datacenter, which is 2 of 3 with RF=3.</p>
<p>With two datacenters at RF=3, there are six replicas in total, so <code>QUORUM</code> needs four. That forces at least one cross-datacenter acknowledgment. <code>LOCAL_QUORUM</code> avoids that WAN latency and keeps working if the other datacenter is completely down. The animated diagram below shows a <code>LOCAL_QUORUM</code> write, including the case where DC2 is unavailable.</p>
<figure class="wp-block-image size-full"><img fetchpriority="high" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/cassandra-best-practices-guidelines-diagram-4.gif" alt="Animated LOCAL_QUORUM write: client, DC1 coordinator, replicas R1-R3 in DC1 and R4-R6 in DC2 over a dashed WAN, including a DC2-down case." width="1200" height="675" /><figcaption>With RF=3 per datacenter, LOCAL_QUORUM waits for 2 of 3 DC1 replicas, so the client never waits on the cross-DC link and DC2 can be down.</figcaption></figure>
<h3>Which consistency level fits which use case?</h3>
<p>The table assumes two datacenters with RF=3 each (six replicas in total).</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Level</th>
<th>Replicas needed</th>
<th>Latency</th>
<th>Availability</th>
<th>Typical use</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>ONE</strong></td>
<td>1, any DC</td>
<td>Lowest</td>
<td>Highest</td>
<td>Data where stale reads are fine</td>
</tr>
<tr>
<td><strong>LOCAL_ONE</strong></td>
<td>1, local DC</td>
<td>Lowest, no WAN</td>
<td>High</td>
<td>Low-value reads in one DC</td>
</tr>
<tr>
<td><strong>QUORUM</strong></td>
<td>4 of 6</td>
<td>Includes WAN</td>
<td>Medium</td>
<td>Single-DC clusters, cross-DC strictness</td>
</tr>
<tr>
<td><strong>LOCAL_QUORUM</strong></td>
<td>2 of 3 local</td>
<td>Low, no WAN</td>
<td>High</td>
<td>Default for multi-DC production</td>
</tr>
<tr>
<td><strong>EACH_QUORUM</strong> (writes only)</td>
<td>2 of 3 in each DC</td>
<td>High</td>
<td>Low</td>
<td>Rare writes that must reach every DC</td>
</tr>
<tr>
<td><strong>ALL</strong></td>
<td>6 of 6</td>
<td>Highest</td>
<td>Lowest</td>
<td>Almost never</td>
</tr>
</tbody>
</table>
</figure>
<h3>When do you get strong consistency?</h3>
<p>You get strong consistency when read replicas plus write replicas is greater than RF. With RF=3, <code>LOCAL_QUORUM</code> on both sides gives 2 + 2 = 4, which is more than 3, so every read overlaps at least one replica that saw the latest write. This gives read-your-writes within that datacenter.</p>
<p>Know the trade-offs. <code>ALL</code> fails as soon as one replica is down, so one node restart becomes an outage. <code>ONE</code> is fast but can return stale data. Use lower levels only where staleness is acceptable, such as video view counters on a media site. Use <code>LOCAL_QUORUM</code> for order and payment data in a retail system.</p>
<h3>How do you set replication correctly?</h3>
<p>Use <code>NetworkTopologyStrategy</code> with an explicit RF per datacenter, and make sure the snitch reports correct datacenter and rack names. Do not use <code>SimpleStrategy</code> in production multi-datacenter clusters, because it ignores topology and can place all replicas in one rack or datacenter.</p>
<p>The example below creates a keyspace with RF=3 in each datacenter, then sets the session consistency in <code>cqlsh</code>:</p>
<p class="code-label"><strong>CQL</strong></p>
<pre><code class="language-sql">CREATE KEYSPACE retail
  WITH replication = {
    'class': 'NetworkTopologyStrategy',
    'dc1': 3,
    'dc2': 3
  };

CONSISTENCY LOCAL_QUORUM;</code></pre>
<p>Each datacenter keeps three replicas, and every later read or write in this <code>cqlsh</code> session waits for two local replicas. The names <code>dc1</code> and <code>dc2</code> must match the datacenter names your snitch reports.</p>
<h2 id="transactions-and-lightweight-transactions-paxos-v2-and-accor">Transactions and Lightweight Transactions: Paxos v2 and Accord in Cassandra 6.0</h2>
<p>Cassandra gives you atomicity and isolation <strong>per partition</strong>. For compare-and-set on a single partition it offers lightweight transactions (LWT, written as <code>IF</code> conditions). It is not a general ACID database today.</p>
<h3>When should you use a lightweight transaction?</h3>
<p>Use LWT only where two writers must not both succeed, such as a unique username or an inventory reservation. Paxos, the consensus protocol behind LWT, needs several round trips between replicas, so it costs far more than a normal write. Don&#8217;t make it your default write path.</p>
<blockquote><p><strong>In plain English:</strong> A normal write is like dropping a note in a mailbox. An LWT is like getting every committee member to agree out loud before the note is filed. It is safer, but it takes much longer.</p></blockquote>
<p>Example: a bank checks that an email is unused before opening an account. The check and the insert must happen as one step, as shown below.</p>
<p class="code-label"><strong>CQL</strong></p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS bank.users_by_email (
  email text PRIMARY KEY,
  user_id uuid,
  created_at timestamp
);

SERIAL CONSISTENCY LOCAL_SERIAL;

INSERT INTO bank.users_by_email (email, user_id, created_at)
VALUES ('asha@example.com', uuid(), toTimestamp(now()))
IF NOT EXISTS;
</code></pre>
<p>The result has an <code>[applied]</code> column. <code>True</code> means the row was inserted. <code>False</code> means the email already exists, and the existing row is returned. Always check this column in your application code. <code>LOCAL_SERIAL</code> limits the Paxos phase to the local datacenter, and <code>SERIAL</code> spans all datacenters.</p>
<h3>Which Paxos settings should you set in production?</h3>
<p>Set <code>paxos_variant</code> to <code>v2</code> together with <code>paxos_state_purging</code> set to <code>repaired</code>. The two settings are independent, so set both deliberately. Test the change in staging and follow the official upgrade notes for your exact version.</p>
<h3>What does Accord in Cassandra 6.0 add?</h3>
<p>Accord adds multi-partition, strict-serializable transactions through <code>BEGIN TRANSACTION</code>. <strong>Cassandra 6.0 is still pre-GA (6.0-alpha3)</strong>, and Accord is beta and off by default. It requires Transactional Cluster Metadata and doesn&#8217;t support counter tables. It also rejects <code>LOCAL_QUORUM</code>, <code>TWO</code> and <code>THREE</code> for Accord operations. Don&#8217;t plan production designs on it yet, because 5.0.9 remains the current GA release.</p>
<h3>Plain writes, logged batch, LWT or Accord: how do they compare?</h3>
<p>This table compares the four options by scope, isolation, cost and status.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Option</th>
<th>Scope</th>
<th>Isolation</th>
<th>Cost</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Plain write</strong></td>
<td>Single partition</td>
<td>Per partition</td>
<td>Lowest</td>
<td>GA</td>
</tr>
<tr>
<td><strong>Logged batch</strong></td>
<td>Multiple tables or partitions</td>
<td>Eventual all-or-nothing, no cross-partition isolation</td>
<td>Extra batchlog writes</td>
<td>GA</td>
</tr>
<tr>
<td><strong>LWT</strong></td>
<td>Single partition, conditional</td>
<td>Serializable per partition</td>
<td>Several round trips</td>
<td>GA</td>
</tr>
<tr>
<td><strong>Accord</strong></td>
<td>Multiple partitions</td>
<td>Strict serializable</td>
<td>Not benchmarked here</td>
<td>Beta, 6.0 pre-GA</td>
</tr>
</tbody>
</table>
</figure>
<h2 id="tombstones-ttl-and-gc-grace-seconds-deleting-data-safely">Tombstones, TTL and gc_grace_seconds: Deleting Data Safely</h2>
<p>A delete in Cassandra writes a <strong>tombstone</strong>, a marker saying the data is deleted, instead of removing it right away. Tombstones are purged only after <code>gc_grace_seconds</code> (default 864000 seconds, 10 days) has passed and only when compaction runs. Until then, reads must scan them, so heavy deletes slow reads.</p>
<blockquote><p><strong>In plain English:</strong> A tombstone is a crossed-out line in a ledger. The old line stays on the page, and the crossing-out is only cleaned up when the ledger is rewritten. Unlike a ledger, Cassandra keeps copies on several nodes, and every copy must see the crossing-out.</p></blockquote>
<h3>Why must repair finish within gc_grace_seconds?</h3>
<p>If a replica misses a delete and the other replicas purge their tombstones, that replica becomes the only holder of the row. The next read or repair copies the old value back, and the deleted row returns as <strong>zombie data</strong>. Repair inside the grace window prevents this.</p>
<p>The animated diagram below shows a delete across three replicas, with the healthy path and the zombie failure path side by side.</p>
<figure class="wp-block-image size-full"><img decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/cassandra-best-practices-guidelines-diagram-6.gif" alt="Timeline of a delete on replicas A, B, C. Repair before day 10 purges tombstones safely; skipping repair lets row X return as zombie data." width="1200" height="675" /><figcaption>Repair must reach every replica within gc_grace_seconds, or a replica that missed the delete can resurrect the row.</figcaption></figure>
<h3>What creates tombstones?</h3>
<p>These patterns produce most tombstone problems in production:</p>
<ul>
<li><strong>Queue-style tables:</strong> rows are inserted, read and deleted in the same partition, so reads scan many dead entries.</li>
<li><strong>Frequent deletes:</strong> every delete adds a marker that stays for at least the grace period.</li>
<li><strong>Partial deletes in wide partitions:</strong> row or range deletes inside a large partition leave many markers next to live data.</li>
<li><strong>Null writes:</strong> explicitly inserting <code>null</code> writes a tombstone. Leave the column out of the statement, or pass the driver&#8217;s unset value (e.g. <code>UNSET_VALUE</code> in the Python driver).</li>
</ul>
<h3>How do you delete data safely?</h3>
<ol>
<li><strong>Use TTL instead of DELETE:</strong> let data expire on its own schedule. Cassandra 5.0 also extends the maximum TTL, which helps for long retention.</li>
<li><strong>Bucket by time:</strong> put a day or hour bucket in the partition key so old data sits in partitions you stop reading.</li>
<li><strong>Avoid large range deletes:</strong> they leave wide tombstones that every read must check.</li>
<li><strong>Use TWCS for TTL-heavy data:</strong> when data is time-windowed and not updated out of order, whole expired SSTables can be dropped.</li>
</ol>
<p>Example: a retail site keeps cart-activity events for seven days. TTL plus TWCS removes them without any application delete.</p>
<p class="code-label"><strong>CQL</strong></p>
<pre><code class="language-sql">CREATE TABLE shop.cart_events (
    user_id    uuid,
    day_bucket date,
    event_time timestamp,
    event_type text,
    PRIMARY KEY ((user_id, day_bucket), event_time)
) WITH CLUSTERING ORDER BY (event_time DESC)
  AND default_time_to_live = 604800
  AND compaction = {
    'class': 'TimeWindowCompactionStrategy',
    'compaction_window_unit': 'DAYS',
    'compaction_window_size': 1
  };

INSERT INTO shop.cart_events (user_id, day_bucket, event_time, event_type)
VALUES (uuid(), '2026-10-20', toTimestamp(now()), 'add_to_cart')
USING TTL 86400;</code></pre>
<p>The table default expires rows after seven days, and the insert overrides it with a one-day TTL. Each daily window becomes its own SSTable group that can be dropped as a whole.</p>
<h3>How do you catch tombstone problems early?</h3>
<p>Set the tombstone warn and failure thresholds in <code>cassandra.yaml</code> and review the guardrails available in your version. Check <code>nodetool tablestats</code> for average and maximum tombstones per slice, and watch the logs for tombstone warnings.</p>
<blockquote><p><strong>Warning:</strong> Don&#8217;t lower <code>gc_grace_seconds</code> just to purge faster. A shorter value frees space sooner, but it raises the risk of zombie data unless repair completes more often than the new value. Lower it only after your repair schedule is proven to beat it.</p></blockquote>
<h2 id="choosing-a-compaction-strategy-stcs-lcs-twcs-or-ucs">Choosing a Compaction Strategy: STCS, LCS, TWCS or UCS</h2>
<p><strong>STCS is still the default in Cassandra 5.0</strong>, but the UCS documentation recommends the Unified Compaction Strategy (UCS) for most workloads. UCS can also be configured to behave like the older strategies. If you stay on classic strategies, use TWCS for TTL-heavy time-series data.</p>
<h3>What does compaction do?</h3>
<p>Compaction is the background process that merges SSTables (the immutable data files on disk) to drop overwritten data and expired tombstones. The strategy you pick decides which files get merged and when.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;"><p><strong>In plain English:</strong> Think of many small notebooks piling up on a desk. Compaction copies the latest notes into fewer, cleaner notebooks and throws the old ones away. The strategy is the rule for which notebooks you tidy together, and how often.</p></blockquote>
<h3>How do the four strategies compare?</h3>
<p>The animated diagram below walks through the decision for one table, and the table after it compares the strategies in qualitative terms.</p>
<figure class="wp-block-image size-full"><img decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/cassandra-best-practices-guidelines-diagram-7.gif" alt="Decision tree: TTL time-series leads to TWCS; otherwise Cassandra 5.0+ leads to UCS; else read-heavy leads to LCS, otherwise STCS." width="1200" height="675" /><figcaption>Walk the questions for one table; UCS is the documented recommendation on 5.0+, while STCS remains the default.</figcaption></figure>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Strategy</th>
<th>Best for</th>
<th>Read amplification</th>
<th>Write amplification</th>
<th>Space overhead</th>
<th>Watch out for</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>STCS</strong></td>
<td>Write-heavy, general use</td>
<td>Higher</td>
<td>Lower</td>
<td>Higher</td>
<td>Large compactions need spare disk</td>
</tr>
<tr>
<td><strong>LCS</strong></td>
<td>Read-heavy, strict latency</td>
<td>Lower</td>
<td>Higher</td>
<td>Lower</td>
<td>Heavy write I/O</td>
</tr>
<tr>
<td><strong>TWCS</strong></td>
<td>TTL time-series</td>
<td>Low for recent windows</td>
<td>Lower</td>
<td>Low when expiry works</td>
<td>Out-of-order updates and deletes</td>
</tr>
<tr>
<td><strong>UCS</strong></td>
<td>Most workloads</td>
<td>Tunable</td>
<td>Tunable</td>
<td>Tunable</td>
<td>Check parameters for your version</td>
</tr>
</tbody>
</table>
</figure>
<h3>Why do the strategies behave differently?</h3>
<ul>
<li><strong>LCS:</strong> keeps data in small sorted levels, so a read touches few files. You pay with more write I/O.</li>
<li><strong>STCS:</strong> favors writes by merging files of similar size. Large merges need free disk space while they run.</li>
<li><strong>TWCS:</strong> groups data into time windows and drops a whole SSTable once everything in it has expired. This only works if data isn&#8217;t updated or deleted out of order.</li>
</ul>
<h3>How do you migrate a table to UCS?</h3>
<ol>
<li><strong>Test first:</strong> try the change on a non-production cluster with realistic data.</li>
<li><strong>Change per table:</strong> use <code>ALTER TABLE</code>, one table at a time.</li>
<li><strong>Mimic your current strategy:</strong> start with settings that behave like STCS or LCS, then tune.</li>
<li><strong>Watch the switch:</strong> track pending compactions and disk use while it runs.</li>
</ol>
<p>Parameter names and defaults can change between versions, so confirm them in the official UCS documentation for your release. The <code>T4</code> value below is the STCS-like setting from the 5.0 docs.</p>
<p class="code-label"><strong>CQL</strong></p>
<pre><code class="language-sql">-- UCS, configured to behave like STCS
ALTER TABLE shop.orders_by_customer
WITH compaction = {
  'class': 'UnifiedCompactionStrategy',
  'scaling_parameters': 'T4'
};

-- TWCS for TTL-heavy time-series data
ALTER TABLE iot.sensor_readings
WITH compaction = {
  'class': 'TimeWindowCompactionStrategy',
  'compaction_window_unit': 'DAYS',
  'compaction_window_size': 1
};</code></pre>
<p>Each statement changes the strategy for one table only. Then check progress on each node:</p>
<p class="code-label"><strong>bash</strong></p>
<pre><code class="language-bash">nodetool compactionstats</code></pre>
<p>This shows pending tasks and running compactions. A pending count that keeps growing after the switch means the node can&#8217;t keep up.</p>
<p>Cassandra 5.0 also adds trie memtables and trie SSTables. They are separate storage features from compaction, and you can evaluate them on their own.</p>
<h2 id="drivers-connection-management-and-security">Drivers, Connection Management and Security</h2>
<p>Use <strong>one long-lived session per application</strong>, prepared statements, token-aware and datacenter-aware load balancing, and an explicitly set local datacenter. Turn on authentication, authorization and TLS from day one, because adding them later means a risky rolling change on a live cluster.</p>
<h3>How should you manage connections?</h3>
<p>Create the <code>Session</code> once at startup and share it across all requests. Opening one per request wastes connections and adds handshake latency. A prepared statement is parsed once on the server, and it lets the driver work out which replicas own the partition.</p>
<blockquote><p><strong>In plain English:</strong> Token-aware routing is like a courier who knows which house holds the parcel and goes straight there, instead of leaving it at a front desk that must pass it on. The &#8220;front desk&#8221; is the coordinator, and skipping it saves one network hop.</p></blockquote>
<p>Set the local datacenter explicitly so traffic never reaches a remote DC by accident. Enable retries and speculative execution (sending a second request when the first is slow) only for idempotent statements, meaning ones that are safe to run twice.</p>
<p>The table below lists the driver settings to check, with the value to use and the reason:</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Setting</th>
<th>Recommended</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Local datacenter</strong></td>
<td>Set explicitly (e.g. dc1)</td>
<td>Keeps requests out of remote DCs</td>
</tr>
<tr>
<td><strong>Load balancing</strong></td>
<td>Token-aware over DC-aware</td>
<td>Sends requests to a replica directly</td>
</tr>
<tr>
<td><strong>Prepared statements</strong></td>
<td>Always, for repeated queries</td>
<td>Parsed once, enables token routing</td>
</tr>
<tr>
<td><strong>Retry and idempotence</strong></td>
<td>Mark idempotent statements only</td>
<td>Avoids duplicate non-idempotent writes</td>
</tr>
<tr>
<td><strong>Default consistency level</strong></td>
<td>LOCAL_QUORUM</td>
<td>Matches RF=3 per DC, no cross-DC wait</td>
</tr>
<tr>
<td><strong>Timeouts</strong></td>
<td>Set client timeout above server timeout</td>
<td>Stops hung requests piling up</td>
</tr>
</tbody>
</table>
</figure>
<h3>How do you configure the Python driver?</h3>
<p>Here is a simplified example using <code>ExecutionProfile</code>, the current way to set policies and consistency:</p>
<p class="code-label"><strong>Python</strong></p>
<pre><code class="language-python">import os
import ssl
from cassandra import ConsistencyLevel
from cassandra.auth import PlainTextAuthProvider
from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT
from cassandra.policies import DCAwareRoundRobinPolicy, TokenAwarePolicy

profile = ExecutionProfile(
    load_balancing_policy=TokenAwarePolicy(DCAwareRoundRobinPolicy(local_dc="dc1")),
    consistency_level=ConsistencyLevel.LOCAL_QUORUM,
    request_timeout=5.0,
)

cluster = Cluster(
    ["10.0.0.1", "10.0.0.2"],
    auth_provider=PlainTextAuthProvider("app_user", os.environ["CASS_PASSWORD"]),
    ssl_context=ssl.create_default_context(cafile="ca.pem"),
    execution_profiles={EXEC_PROFILE_DEFAULT: profile},
)
session = cluster.connect("shop")  # create once, reuse everywhere

stmt = session.prepare("SELECT * FROM orders_by_customer WHERE customer_id = ?")
stmt.is_idempotent = True
rows = session.execute(stmt, [customer_id])
</code></pre>
<p>✅ One shared session, local DC set, <code>LOCAL_QUORUM</code> by default, TLS on, and a prepared read marked idempotent.</p>
<h3>How do you configure the C# driver?</h3>
<p>With the DataStax C# driver (<code>CassandraCSharpDriver</code>), build the <code>Cluster</code> once and register the session as a singleton:</p>
<p class="code-label"><strong>C#</strong></p>
<pre><code class="language-csharp">using Cassandra;

var cluster = Cluster.Builder()
    .AddContactPoints("10.0.0.1", "10.0.0.2")
    .WithLocalDatacenter("dc1")
    .WithCredentials("app_user", Environment.GetEnvironmentVariable("CASS_PASSWORD"))
    .WithSSL()
    .Build();

var session = await cluster.ConnectAsync("shop"); // singleton

var prepared = await session.PrepareAsync(
    "SELECT * FROM orders_by_customer WHERE customer_id = ?");

var bound = prepared.Bind(customerId)
    .SetConsistencyLevel(ConsistencyLevel.LocalQuorum)
    .SetIdempotence(true);

RowSet rows = await session.ExecuteAsync(bound);
</code></pre>
<p>The local DC is named, the statement is prepared and bound, and consistency is <code>LocalQuorum</code>.</p>
<h3>How do you secure a Cassandra cluster?</h3>
<p>Enable <code>PasswordAuthenticator</code> and <code>CassandraAuthorizer</code> in <code>cassandra.yaml</code>. Create your own superuser, then disable or drop the default <code>cassandra</code> role. Give each application a role with only the permissions it needs.</p>
<p class="code-label"><strong>CQL</strong></p>
<pre><code class="language-sql">CREATE ROLE analyst WITH PASSWORD = 'use-a-strong-secret' AND LOGIN = true;
GRANT SELECT ON KEYSPACE clinic TO analyst;
</code></pre>
<ul>
<li><strong>TLS:</strong> Enable client-to-node and node-to-node encryption. Use mutual TLS where you control the clients.</li>
<li><strong>Dynamic data masking (5.0):</strong> Hides sensitive column values from roles without unmask rights. Example: mask patient identifiers for analyst roles in a healthcare system.</li>
<li><strong>CIDR authorizer (5.0):</strong> Limits which networks a role can connect from.</li>
<li><strong>Guardrails:</strong> Added in 4.1 and extended in 5.0, they set warn and fail limits that stop risky operations, such as oversized partitions or too many tables.</li>
</ul>
<h2 id="repair-backup-and-restore-best-practices">Repair, Backup and Restore Best Practices</h2>
<p>Run <strong>repair on every node within each gc_grace_seconds window</strong>. With the default of 864000 seconds that means at least once every 10 days, and in practice more often to leave a safety margin. Take scheduled snapshots with retention, enable commit log archiving for point-in-time restore, and test restores regularly.</p>
<h3>What does Cassandra repair do?</h3>
<p>Repair compares the replicas of the same data using Merkle trees (hash trees that summarize ranges of data) and streams any differences between nodes. It fixes data that a replica missed, for example while the node was down.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;"><p><strong>In plain English:</strong> Three people each keep a copy of the same notebook. Repair is the meeting where they compare page summaries, find pages that differ, and fill in the gaps. Unlike real people, the nodes compare hashes first, so they only copy the pages that really differ.</p></blockquote>
<h3>How should you schedule repair?</h3>
<p>Run repair regularly in small pieces instead of rarely in one large job. Follow these rules:</p>
<ul>
<li><strong>Prefer incremental repair:</strong> on versions that support it, it only checks data not yet repaired. Use full repair only when the release notes or a recovery task call for it.</li>
<li><strong>Use -pr on every node:</strong> the primary-range option repairs only the ranges a node owns first, so the same data isn&#8217;t compared three times.</li>
<li><strong>Go node by node:</strong> running repair on all nodes at once adds heavy streaming and compaction load.</li>
<li><strong>Automate it:</strong> open-source Cassandra has no NodeSync equivalent from DSE. Auto Repair (built-in scheduling) arrived in the 5.0.8+ backport and needs explicit opt-in. Otherwise use an external scheduler. Verify against the release notes for your exact version before enabling.</li>
<li><strong>Watch TTL-heavy tables:</strong> expired data becomes tombstones, and a replica that missed one can bring old values back after the purge. This is the same zombie-data risk described in the tombstones section.</li>
</ul>
<p>Here is a simplified example of repairing the primary ranges of one keyspace on the current node:</p>
<p class="code-label"><strong>BASH</strong></p>
<pre><code class="language-bash">nodetool repair -pr shop_ks</code></pre>
<p>✅ Repairs only the ranges this node owns. Repeat on the next node when it finishes.</p>
<h3>Which backup method should you use?</h3>
<p>Use snapshots as the base and add commit log archiving if you need point-in-time recovery. The table compares the four common methods:</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Method</th>
<th>What it gives you</th>
<th>Restore path</th>
<th>Limit</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>nodetool snapshot</strong></td>
<td>Consistent point-in-time copy of SSTables per node</td>
<td>Copy files back or stream them</td>
<td>Only as fresh as the last run; lives on the same disk until you copy it away</td>
</tr>
<tr>
<td><strong>Incremental backups</strong></td>
<td>Hard links to SSTables flushed since the last snapshot</td>
<td>Restore the snapshot, then add the new files</td>
<td>Files pile up until you move and clean them</td>
</tr>
<tr>
<td><strong>Commit log archiving</strong></td>
<td>Writes between snapshots, for point-in-time restore</td>
<td>Replay logs up to a chosen timestamp</td>
<td>Needs archive and restore commands configured in advance</td>
</tr>
<tr>
<td><strong>sstableloader / nodetool refresh</strong></td>
<td>Loading tools, not backups</td>
<td>Stream (sstableloader) or copy files (refresh)</td>
<td>Needs the schema to exist first</td>
</tr>
</tbody>
</table>
</figure>
<p>Take a named snapshot, then copy it off the node:</p>
<p class="code-label"><strong>BASH</strong></p>
<pre><code class="language-bash">nodetool snapshot -t daily_2026-10-20 shop_ks</code></pre>
<p>Creates a snapshot tagged <code>daily_2026-10-20</code> for every table in <code>shop_ks</code>. Store it off the node, because a snapshot on the same disk does not survive disk loss.</p>
<h3>How do you restore and test a backup?</h3>
<p>Restore the schema first, then the data, then run repair. The animated diagram below shows the full restore flow, step by step.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/cassandra-best-practices-guidelines-diagram-9.gif" alt="Restore flow: backup store feeds a new test cluster with schema, SSTables, and point-in-time commit logs, then repair and row count validation." width="1200" height="675" /></figure>
<p>A restore test loads schema, snapshot SSTables and archived commit logs into a fresh cluster, then repairs and validates row counts against the source.</p>
<ol>
<li><strong>Restore the schema:</strong> apply the saved CQL so tables exist.</li>
<li><strong>Load the data:</strong> stream SSTables with <code>sstableloader</code>, or copy files into the table directory and run <code>nodetool refresh</code>.</li>
<li><strong>Replay commit logs:</strong> stop at the target timestamp for point-in-time recovery.</li>
<li><strong>Repair and validate:</strong> run repair, then compare row counts with the source.</li>
</ol>
<p class="code-label"><strong>BASH</strong></p>
<pre><code class="language-bash">sstableloader -d 10.0.1.11,10.0.1.12 /restore/shop_ks/orders_by_customer</code></pre>
<p>Streams the SSTables in that directory to the live cluster. The path must end in <code>keyspace/table</code>.</p>
<p><strong>A backup that was never restored is untested.</strong> Run the restore on a separate cluster on a schedule, and time it. For example, a retail team can check that last night&#8217;s order tables match production before trusting the backup for peak season.</p>
<h2 id="operations-monitoring-and-upgrades">Operations, Monitoring and Upgrades</h2>
<p>Run a supported Java version, keep OS and disk settings aligned with Cassandra&#8217;s recommendations, watch partition size and pending compactions, and upgrade by rolling one node at a time to a supported release line. The sections below cover each of these.</p>
<h3>Which Java, disk and OS settings should you check?</h3>
<p>Cassandra 5.0.x runs on <strong>Java 11 and Java 17</strong>. Pick one of them and use it on every node.</p>
<ul>
<li><strong>Disks:</strong> Put the commit log and data directories on separate disks where possible, so sequential commit log writes don&#8217;t compete with compaction and reads.</li>
<li><strong>Free space:</strong> Keep free disk for compaction. STCS in particular needs spare room when it merges large SSTables.</li>
<li><strong>Heap:</strong> Start from the JVM options files shipped with your version, not values copied from old blog posts. Those were tuned for other Java and Cassandra releases.</li>
<li><strong>Swap and limits:</strong> Disable swap and raise ulimits and file-descriptor limits as the Cassandra docs recommend.</li>
<li><strong>Clocks:</strong> Run NTP or chrony on every node. Cassandra resolves conflicting writes by timestamp, so a drifting clock can make an older write win.</li>
</ul>
<h3>What should you monitor?</h3>
<p>Start with partition size and compaction backlog, then add latency and GC. The table shows each signal and what to do about it.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Signal</th>
<th>Where to see it</th>
<th>What it means</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Large partitions</strong></td>
<td><code>tablehistograms</code>, <code>tablestats</code> (compacted partition maximum bytes)</td>
<td>A key is growing without a bound</td>
<td>Add a bucket to the partition key</td>
</tr>
<tr>
<td><strong>Pending compactions</strong></td>
<td><code>nodetool compactionstats</code></td>
<td>Compaction can&#8217;t keep up with writes</td>
<td>Review the strategy and disk throughput</td>
</tr>
<tr>
<td><strong>Dropped messages</strong></td>
<td><code>nodetool tpstats</code></td>
<td>Node is overloaded</td>
<td>Find the busy table or slow disk</td>
</tr>
<tr>
<td><strong>Latency percentiles</strong></td>
<td>Metrics, <code>tablehistograms</code></td>
<td>Slow replicas or wide reads</td>
<td>Check partitions, tombstones, GC</td>
</tr>
<tr>
<td><strong>Tombstone warnings</strong></td>
<td>System log</td>
<td>Reads are scanning many deleted cells</td>
<td>Use TTL and bucketing</td>
</tr>
<tr>
<td><strong>Hinted handoff, disk, GC pauses</strong></td>
<td>Metrics and logs</td>
<td>A node was down, or space and memory are tight</td>
<td>Alert early and investigate</td>
</tr>
</tbody>
</table>
</figure>
<p>Use these commands to find oversized partitions:</p>
<p class="code-label"><strong>Bash</strong></p>
<pre><code class="language-bash"># Partition size and cell count percentiles for one table
nodetool tablehistograms retail orders_by_customer

# Look for "Compacted partition maximum bytes" in the output
nodetool tablestats retail.orders_by_customer</code></pre>
<p>The first command shows percentile distributions. The second gives the largest partition on that node.</p>
<h3>How do you upgrade Cassandra safely?</h3>
<p>Upgrade one node at a time, one major version at a time. Versions 3.0 and 3.11 reached end-of-life when 5.0 went GA on September 5, 2024, so plan to leave them. The 4.1 line is at 4.1.12, the 4.0 line is at 4.0.21, and <strong>5.0.9 is the current GA</strong>. Don&#8217;t run 6.0-alpha3 in production.</p>
<blockquote><p><strong>In plain English:</strong> A rolling upgrade is like renovating a hotel one room at a time. Guests keep using the other rooms, so the hotel never closes. It only works if the other rooms are in good shape, which is why you repair first.</p></blockquote>
<ol>
<li><strong>Read NEWS.txt:</strong> Check the target version for required steps and breaking changes.</li>
<li><strong>Snapshot:</strong> Take a snapshot on every node.</li>
<li><strong>Repair and check health:</strong> Finish a repair and confirm all nodes are up.</li>
<li><strong>Freeze schema:</strong> Make no schema changes until every node is upgraded.</li>
<li><strong>Roll each node:</strong> Drain, stop, upgrade, start, then check.</li>
<li><strong>Rewrite SSTables if needed:</strong> Run <code>upgradesstables</code> only when the release notes ask for it.</li>
</ol>
<p>This is the command sequence for one node:</p>
<p class="code-label"><strong>Bash</strong></p>
<pre><code class="language-bash">nodetool snapshot -t pre_upgrade retail
nodetool drain                      # flush memtables, stop accepting writes
sudo systemctl stop cassandra
# install the new Cassandra package or binaries here
sudo systemctl start cassandra
nodetool status                     # wait for UN (Up/Normal) before the next node
nodetool upgradesstables            # only if the release notes require it</code></pre>
<p>Streaming during node operations is faster since 4.0, because zero-copy streaming sends eligible whole SSTables without rewriting them.</p>
<h2 id="common-cassandra-anti-patterns">Common Cassandra Anti-Patterns</h2>
<p>These are the mistakes that most often cause slow reads, hot nodes and data resurrecting. Each row below names the symptom you will see and the fix, with a pointer to the section that explains the reason.</p>
<blockquote><p><strong>In plain English:</strong> An anti-pattern is a habit that looks fine on day one and hurts later. Think of a shop that stacks every receipt in one box: it works for a week, then finding anything takes all afternoon.</p></blockquote>
<p>The table lists 15 Cassandra anti-patterns, the symptom of each, and the fix.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Anti-pattern</th>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Unbounded partitions (no bucket)</strong></td>
<td>Slow reads, heavy compaction and repair, one busy node</td>
<td>Add a day or hour bucket to the partition key and keep partitions under about 100 MB (see the data modeling section).</td>
</tr>
<tr>
<td><strong>Low-cardinality partition key</strong></td>
<td>A few nodes take most of the traffic</td>
<td>Choose a high-cardinality key such as customer_id instead of country or status.</td>
</tr>
<tr>
<td><strong>Queue-style table with frequent deletes</strong></td>
<td>Reads slow down, tombstone warnings in logs</td>
<td>Use TTL, time bucketing and TWCS (see the tombstones section).</td>
</tr>
<tr>
<td><strong>Explicit null writes</strong></td>
<td>Unexpected tombstones</td>
<td>Leave the column out of the INSERT or use unset values in the driver.</td>
</tr>
<tr>
<td><strong>ALLOW FILTERING in app queries</strong></td>
<td>Latency grows with table size</td>
<td>Create a query table, or use SAI with a partition key restriction.</td>
</tr>
<tr>
<td><strong>Legacy secondary index on a high-cardinality column</strong></td>
<td>Slow, scattered reads</td>
<td>Use a denormalized table or an SAI index (see the indexing section).</td>
</tr>
<tr>
<td><strong>ALL or cross-DC QUORUM by default</strong></td>
<td>Requests fail with one node down, or pay cross-datacenter latency</td>
<td>Use LOCAL_QUORUM with RF=3 per datacenter.</td>
</tr>
<tr>
<td><strong>Rare or skipped repair beyond gc_grace_seconds</strong></td>
<td>Deleted data comes back (zombie data)</td>
<td>Repair every node inside each 10-day window with the default, with a safety margin.</td>
</tr>
<tr>
<td><strong>LWT everywhere</strong></td>
<td>High write latency, contention on hot rows</td>
<td>Keep IF conditions for unique checks and reservations only.</td>
</tr>
<tr>
<td><strong>Large logged batches across many partitions</strong></td>
<td>Coordinator overload, batch size warnings</td>
<td>Use batches only to keep a few denormalized tables in sync.</td>
</tr>
<tr>
<td><strong>One session per request</strong></td>
<td>Connection churn, high latency</td>
<td>Create one long-lived Session and share it.</td>
</tr>
<tr>
<td><strong>No local DC on the driver</strong></td>
<td>Traffic reaches a remote datacenter</td>
<td>Set the local datacenter and use token-aware routing.</td>
</tr>
<tr>
<td><strong>Snapshots never tested</strong></td>
<td>Restore fails when you need it</td>
<td>Restore on a separate cluster on a schedule and time it.</td>
</tr>
<tr>
<td><strong>Staying on EOL 3.x</strong></td>
<td>No fixes, harder upgrade later</td>
<td>Plan a path to 5.0.x, one major version at a time.</td>
</tr>
<tr>
<td><strong>Running 6.0 alpha in production</strong></td>
<td>Unstable, unsupported behavior</td>
<td>Stay on 5.0.9 until 6.0 is GA.</td>
</tr>
</tbody>
</table>
</figure>
<h3>Which anti-patterns should you fix first?</h3>
<p>Start with the ones that can lose or resurrect data: skipped repair and untested snapshots. Then fix partition design, because a bad partition key is the hardest thing to change once data exists. Driver settings and consistency defaults are cheap to correct and worth doing early.</p>
<h2 id="cassandra-production-readiness-checklist">Cassandra Production Readiness Checklist</h2>
<p>Use this Cassandra production checklist as a go-live gate. Each item has a check you can run or verify, so you can mark it done or not done. Any item you can&#8217;t verify is a risk to note before launch.</p>
<h3>Cluster and Driver Setup</h3>
<p>These items confirm the cluster layout and how clients connect to it.</p>
<ol>
<li><strong>Supported version:</strong> every node runs a supported line, preferably 5.0.x (5.0.9 is the latest GA). No 3.x nodes and no 6.0-alpha builds.</li>
<li><strong>Replication:</strong> <code>DESCRIBE KEYSPACE</code> shows NetworkTopologyStrategy with RF=3 in each datacenter.</li>
<li><strong>Driver settings:</strong> the local datacenter is set explicitly and the default consistency is LOCAL_QUORUM. One long-lived session is shared by the application.</li>
<li><strong>Host settings:</strong> the JVM is on Java 11 or 17, swap is off, ulimits are raised, and NTP or chrony reports synced clocks on every node.</li>
</ol>
<h3>Data Model and Storage</h3>
<p>These items catch the design problems that are expensive to fix after launch.</p>
<ol start="5">
<li><strong>Query shape:</strong> each application query reads one partition, and none uses <code>ALLOW FILTERING</code>.</li>
<li><strong>Partition size:</strong> <code>nodetool tablehistograms</code> and <code>tablestats</code> show compacted partition maximum bytes under about 100 MB.</li>
<li><strong>Bounded keys:</strong> no partition key grows forever. A time bucket is in the key wherever volume is unbounded.</li>
<li><strong>Table settings:</strong> every table has a deliberate TTL and compaction strategy (UCS, or TWCS for TTL-heavy data).</li>
</ol>
<h3>Recovery and Upgrades</h3>
<p>These items prove you can recover from failure and keep deleted data deleted.</p>
<ol start="9">
<li><strong>Repair:</strong> the schedule covers every node within <code>gc_grace_seconds</code> (864000 seconds by default), with a safety margin.</li>
<li><strong>Backups:</strong> snapshots run on a schedule with retention, are copied off the node, and commit log archiving is on.</li>
<li><strong>Restore test:</strong> a restore into a separate cluster has been completed and timed, and the time is written down.</li>
<li><strong>Upgrade runbook:</strong> a written rolling-upgrade procedure exists and has been rehearsed in staging.</li>
</ol>
<h3>Security and Monitoring</h3>
<p>These items keep the cluster protected and give you early warning.</p>
<ol start="13">
<li><strong>Access control:</strong> authentication, authorization and TLS (client-to-node and node-to-node) are enabled, and the default <code>cassandra</code> superuser is replaced.</li>
<li><strong>Guardrails:</strong> warn and fail limits are configured, for example for partition size and table count.</li>
<li><strong>Alerts:</strong> alerts fire on latency percentiles, pending compactions, disk usage and dropped messages, and each alert has a named owner.</li>
</ol>
<h2 id="key-takeaways">Key takeaways</h2>
<p>These are the points to remember from this guide before you take a Cassandra cluster to production:</p>
<ul>
<li><strong>Model from queries:</strong> design one table per query, and accept denormalization. Cassandra has no joins, so one write to several query tables is cheaper than reads that need many partitions.</li>
<li><strong>Bound your partitions:</strong> keep each partition under about 100 MB and about 100,000 items. Add a time bucket (e.g. day or hour) to the partition key for busy keys, and check the real size with <code>nodetool tablehistograms</code> and <code>tablestats</code>.</li>
<li><strong>Use LOCAL_QUORUM with RF=3:</strong> set <code>NetworkTopologyStrategy</code> with RF=3 per datacenter, and use LOCAL_QUORUM for reads and writes. This gives read-your-writes inside the datacenter, tolerates one node down and avoids cross-DC latency.</li>
<li><strong>Prefer TTL over deletes:</strong> explicit deletes and null writes create tombstones that slow reads. Use TTL, time bucketing and TWCS for time-series data instead.</li>
<li><strong>Finish repair inside gc_grace_seconds:</strong> the default is 864000 seconds (10 days). If repair is late, a replica that missed a delete can bring the old data back. Schedule it with the opt-in Auto Repair (5.0.8+) or an external scheduler.</li>
<li><strong>Test your restores:</strong> schedule snapshots with retention, store them off the node, archive commit logs for point-in-time recovery, and restore into a separate cluster on a schedule. A backup that was never restored is untested.</li>
<li><strong>Pick compaction and indexes deliberately:</strong> STCS is still the 5.0 default, but UCS is recommended for most workloads, and TWCS suits TTL-heavy data. SAI helps with secondary lookups, but it does not replace query-first modeling. Test both on production-size data first.</li>
<li><strong>Stay on supported, GA releases:</strong> 5.0.9 is the current GA release, and 3.x is end-of-life. Treat Accord and Cassandra 6.0 (still 6.0-alpha3) as pre-GA, and keep them out of production designs.</li>
</ul>
<p>That&#8217;s all! Happy coding!</p>
<p>Did this Cassandra best practices guide help you? Please share your questions and your own experience in the comments below.</p>
<p>Bookmark this page for your next production review, and share it with your team. Subscribe to TheCodeBuzz for more database best practices guides.</p>
<h2 id="faq">FAQ</h2>
<h3><strong>What is the ideal partition size in Cassandra?</strong></h3>
<p>Keep each partition under about 100 MB and under about 100,000 items. The hard practical limit is 2 billion cells per partition, but you should stay far below it. Check real sizes with <code>nodetool tablehistograms</code> and <code>nodetool tablestats</code> (look at the compacted partition maximum bytes), and add a time bucket to the partition key if a partition keeps growing.</p>
<h3><strong>Which consistency level should I use in production, QUORUM or LOCAL_QUORUM?</strong></h3>
<p>Use LOCAL_QUORUM for multi-datacenter clusters, with RF=3 in each datacenter. It needs a majority (2 of 3) in the coordinator&#8217;s datacenter only, so it avoids cross-DC latency and keeps working if another datacenter is down. QUORUM counts replicas across all datacenters, and EACH_QUORUM is supported for writes only.</p>
<h3><strong>How often should I run nodetool repair, and why must it finish within gc_grace_seconds?</strong></h3>
<p>Run repair on every node at least once within each gc_grace_seconds window, which is 10 days (864000 seconds) by default. In practice, repair more often to leave a safety margin. If a replica missed a delete and the tombstone is purged on the other replicas, the old value can come back as zombie data. Cassandra 5.0.8 and later include Auto Repair, which needs explicit opt-in, so check the release notes for your exact version before enabling it.</p>
<h3><strong>Should I use Storage-Attached Indexes (SAI) or denormalize into more tables?</strong></h3>
<p>Denormalize into tables for your hot, latency-critical queries, and use SAI for secondary lookups where a partition-key query is not possible. Avoid legacy secondary indexes (2i) in new designs. An SAI query without a partition key restriction can fan out across nodes and add latency variance, so test it on production-size data.</p>
<h3><strong>Does Cassandra support ACID transactions, and what changes in Cassandra 6.0?</strong></h3>
<p>Not fully. Cassandra gives atomicity and isolation per partition, and lightweight transactions (<code>IF</code> conditions) give compare-and-set on a single partition. For Paxos-based LWT, the recommended production setup is <code>paxos_variant</code> v2 with <code>paxos_state_purging</code> set to repaired. Cassandra 6.0 adds Accord, which offers multi-partition, strict-serializable transactions through <code>BEGIN TRANSACTION</code>. Accord is beta and off by default, and 6.0 is still pre-GA (6.0-alpha3), so 5.0.9 remains the current GA release and you should not plan production designs on Accord yet.</p>
<h2 id="sources">Sources and further reading</h2>
<p>&nbsp;</p>
<ul>
<li><a href="https://cassandra.apache.org/_/download.html" target="_blank" rel="noopener">Apache Cassandra: Downloading Cassandra</a> (Apache Cassandra project)</li>
<li><a href="https://cassandra.apache.org/_/blog/Apache-Cassandra-5.0-Announcement.html" target="_blank" rel="noopener">Announcing Apache Cassandra® 5.0</a> (Apache Cassandra project)</li>
<li><a href="https://cassandra.apache.org/doc/stable/cassandra/new/" target="_blank" rel="noopener">New Features in Apache Cassandra 5.0</a> (Apache Cassandra project)</li>
</ul>
<p>&nbsp;</p><p>The post <a href="https://thecodebuzz.com/cassandra-best-practices-guidelines/">Cassandra Best Practices: Data Modeling, Consistency, Operations</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://thecodebuzz.com/cassandra-best-practices-guidelines/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Agentic Workflow Best Practices: Architecture and Checklist</title>
		<link>https://thecodebuzz.com/agentic-workflow-best-practices/</link>
					<comments>https://thecodebuzz.com/agentic-workflow-best-practices/#respond</comments>
		
		<dc:creator><![CDATA[Izzy]]></dc:creator>
		<pubDate>Sun, 18 Oct 2026 17:50:44 +0000</pubDate>
				<category><![CDATA[AI Engineering Best Practices]]></category>
		<category><![CDATA[Agentic AI]]></category>
		<category><![CDATA[AI Agents]]></category>
		<category><![CDATA[LLM Workflows]]></category>
		<category><![CDATA[MCP]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[Production AI]]></category>
		<guid isPermaLink="false">https://thecodebuzz.com/?p=30881</guid>

					<description><![CDATA[<p>Agentic workflow best practices for architecture, agent loops, security, tracing and evaluation, with Python and C# code and a production checklist.</p>
<p>The post <a href="https://thecodebuzz.com/agentic-workflow-best-practices/">Agentic Workflow Best Practices: Architecture and Checklist</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>This guide covers agentic workflow best practices: architecture, the stages of a run, and a production checklist. It&#8217;s written for engineers and architects building AI agents and LLM features, and for managers, founders and students who want to know how these systems stay under control. Code is in Python and C# (.NET).</p>
<p>Note- Version-specific facts are dated 2026-10-17.</p>
<p>&nbsp;</p>
<p>The main principle is simple: use the smallest thing that works. A single call to an LLM with document lookup (retrieval) and a few examples is often enough. Agents trade latency and cost for flexibility. In simple terms, an LLM (large language model) is the text-generating AI behind chat assistants. In simple terms, an agentic workflow is a multi-step AI task in which a model uses tools and data while your code sets the steps, limits and checks. In simple terms, an agent is a system where the model itself decides the next step and which tool to use.</p>
<p>Next come the workflow-versus-agent choice, design patterns, the agent loop, durable execution, human approval, security with the OWASP Agentic Top 10, tracing, evaluation, and cost and latency.</p>
<blockquote>
<p><strong>Version note:</strong> As of 2026-10-17, this guide targets LangGraph 1.2.16, OpenAI Agents SDK 0.23.1, Microsoft Agent Framework 1.0, MCP specification revision 2026-07-28 and the OWASP Top 10 for Agentic Applications 2026.</p>
<p>&nbsp;</p>
<p><strong>In this guide</strong></p>
</blockquote>
<ul>
<li><a href="#in-plain-english-what-is-an-agentic-workflow">In Plain English: What Is an Agentic Workflow?</a></li>
<li><a href="#agentic-workflow-best-practices-at-a-glance">Agentic Workflow Best Practices at a Glance</a></li>
<li><a href="#agentic-ai-workflow-architecture-where-it-fits-in-the-pipeli">Agentic AI Workflow Architecture: Where It Fits in the Pipeline</a></li>
<li><a href="#agent-workflow-vs-autonomous-agent-choosing-patterns">Agent Workflow vs Autonomous Agent: Choosing Patterns</a></li>
<li><a href="#build-the-agent-loop-state-limits-durability-and-approvals">Build the Agent Loop: State, Limits, Durability and Approvals</a></li>
<li><a href="#secure-agentic-workflows-with-owasp-agentic-top-10-controls">Secure Agentic Workflows with OWASP Agentic Top 10 Controls</a></li>
<li><a href="#observe-evaluate-and-tune-agents-tracing-evals-cost-and-late">Observe, Evaluate and Tune Agents: Tracing, Evals, Cost and Latency</a></li>
<li><a href="#common-anti-patterns-in-agentic-workflows">Common Anti-Patterns in Agentic Workflows</a></li>
<li><a href="#production-readiness-checklist-for-ai-agents">Production Readiness Checklist for AI Agents</a></li>
<li><a href="#glossary-in-plain-english">Glossary in Plain English</a></li>
<li><a href="#key-takeaways">Key Takeaways</a></li>
<li><a href="#faq">FAQ</a></li>
</ul>
<h2> </h2>
<h2 id="in-plain-english-what-is-an-agentic-workflow">What Is an Agentic Workflow?</h2>
<p><strong>Tip</strong>: what is an agentic workflow? Think of a capable assistant working through a to-do list. The assistant can look things up, use tools and check their own work, while a manager sets a time limit and a budget.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> A human assistant who gets stuck usually notices. Here the &#8220;assistant&#8221; is software that writes likely-sounding answers, so it can be confidently wrong. That is why it needs checks a human assistant would not.</p>
</blockquote>
<p>The assistant repeats one loop: think, do one thing, look at the result, then decide whether to continue, stop or ask a person. The animated diagram below shows this loop, including the step limit and the hand-off to a person.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/agentic-workflow-best-practices-diagram-1.gif" alt="Flow from a request to plan, tool, result check and a Done decision that loops back, answers, or asks a person, with a step limit gauge." width="1200" height="675" /><figcaption>The assistant repeats plan, act, check until done, within a step limit, and hands risky or unclear cases to a person.</figcaption></figure>
<p>These are the four terms you need for the rest of the article:</p>
<ul>
<li><strong>Agentic workflow:</strong> In simple terms, a task where an AI repeats the think, act, check loop until the job is done.</li>
<li><strong>Agent:</strong> In simple terms, the AI part that chooses its own next step.</li>
<li><strong>LLM:</strong> In simple terms, a large language model, the program that reads and writes text.</li>
<li><strong>Tool call:</strong> In simple terms, the AI asking your code to run an action, such as a search or a database lookup.</li>
</ul>
<p><strong>Why it matters:</strong> without limits, state and checks, an agent can loop forever, spend money, or take a wrong action nobody notices.</p>
<h3>Workflow or Agent: Who Decides the Steps?</h3>
<p>The difference is who picks the path. In a <strong>workflow</strong>, your code decides the steps and the model fills them in. In an <strong>agent</strong>, the model decides the steps and the tools to use. This difference drives the agent workflow vs autonomous agent choice covered later.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/agentic-workflow-best-practices-photo-1.jpg" alt="innovation, business, businessman" width="1200" height="675" /></p>
<figcaption>Image by <a href="https://pixabay.com/users/jarmoluk-143740/" target="_blank" rel="noopener nofollow">jarmoluk</a> from <a href="https://pixabay.com/photos/innovation-business-businessman-561388/" target="_blank" rel="noopener nofollow">Pixabay</a></figcaption>
</figure>
<h2 id="agentic-workflow-best-practices-at-a-glance">Agentic Workflow Best Practices at a Glance</h2>
<p>The rules below run in order from architecture decisions to day-to-day operations, so you can read the table top to bottom as a design sequence.</p>
<p>Four terms appear in the table. In simple terms, an <strong>idempotent</strong> call gives the same result when repeated. <strong>Least agency</strong> means giving an agent only the autonomy and permissions its task needs. <strong>Model tiering</strong> means using a small, cheap model for easy steps and a larger one for hard steps. A <strong>trajectory</strong> is the full sequence of steps and tool calls in one run.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> an idempotent action is like a light switch marked &#8220;set to ON&#8221;. Pressing it twice leaves the light on. A toggle switch is not idempotent, because pressing it twice undoes the first press. This is why a retry is safe for one and risky for the other.</p>
</blockquote>
<p>This table shows what to do and what to avoid in each area of an agentic workflow:</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Area</th>
<th>Do this</th>
<th>Avoid this</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Architecture choice</strong></td>
<td>Pick the simplest design that meets the task.</td>
<td>Using an autonomous agent where a fixed workflow works.</td>
</tr>
<tr>
<td><strong>Patterns</strong></td>
<td>Use named agentic workflow design patterns with clear roles.</td>
<td>Adding agents without distinct tools or permissions.</td>
</tr>
<tr>
<td><strong>State</strong></td>
<td>Keep an explicit state object outside the prompt.</td>
<td>Holding state only in the conversation text.</td>
</tr>
<tr>
<td><strong>Tool design</strong></td>
<td>Use typed schemas, single-purpose tools, idempotent writes and input validation.</td>
<td>Free-text arguments and &#8220;do everything&#8221; tools.</td>
</tr>
<tr>
<td><strong>Loop limits and budgets</strong></td>
<td>Cap steps, time and cost per run.</td>
<td>Open-ended loops.</td>
</tr>
<tr>
<td><strong>Retries and durability</strong></td>
<td>Checkpoint each step and retry only safe calls.</td>
<td>Restarting from zero or retrying non-idempotent writes.</td>
</tr>
<tr>
<td><strong>Human approval</strong></td>
<td>Gate risky actions by risk level.</td>
<td>Approving every step.</td>
</tr>
<tr>
<td><strong>Security</strong></td>
<td>Use least agency and task-scoped credentials. Treat tool output and retrieved content as untrusted data.</td>
<td>Broad long-lived keys, or obeying text found in tool results.</td>
</tr>
<tr>
<td><strong>Observability</strong></td>
<td>Trace each run as one tree.</td>
<td>Logging only the final answer.</td>
</tr>
<tr>
<td><strong>Evaluation</strong></td>
<td>Score trajectories and keep a regression suite.</td>
<td>Checking only final-answer quality.</td>
</tr>
<tr>
<td><strong>Cost</strong></td>
<td>Use model tiering, prompt caching and context compression.</td>
<td>Sending the largest model the full history on every step.</td>
</tr>
<tr>
<td><strong>Dependency pinning</strong></td>
<td>Pin framework, SDK and protocol versions.</td>
<td>Floating versions on fast-moving or 0.x packages.</td>
</tr>
</tbody>
</table>
</figure>
<p>Each row is explained with its trade-off in the sections below.</p>
<h2 id="agentic-ai-workflow-architecture-where-it-fits-in-the-pipeli">Agentic AI Workflow Architecture: Where It Fits in the Pipeline</h2>
<p>The agentic layer sits between retrieval and the user. It calls the knowledge built during ingestion, the tools and the LLM, while evaluation and operations wrap around the whole thing. The animated diagram below shows a document flowing into storage, then a user question driving the agent.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/agentic-workflow-best-practices-diagram-3.gif" alt="Pipeline from ingestion to vector store and retrieval, with agent control calling the LLM, retrieval and tools, over an evaluation band." width="1200" height="675" /></p>
<figcaption>The agent layer sits after retrieval and calls retrieval, tools and the LLM, while evaluation and operations record traces for every step.</figcaption>
</figure>
<h3>What does each pipeline stage do for the agent?</h3>
<p>Each stage prepares something the agent uses at run time. Weak work early in the pipeline shows up later as poor agent decisions.</p>
<ul>
<li><strong>Ingestion:</strong> loads and cleans source documents.</li>
<li><strong>Chunking:</strong> splits documents into pieces. In simple terms, a chunk is one small passage cut from a longer document.</li>
<li><strong>Embeddings:</strong> turn each chunk into numbers. In simple terms, an embedding is a list of numbers that places a text on a map of meaning.</li>
<li><strong>Vector store:</strong> keeps those embeddings searchable. In simple terms, a vector store is a database that finds texts by meaning instead of exact words.</li>
<li><strong>Retrieval:</strong> fetches the best-matching chunks for a question. In simple terms, retrieval is looking up the most relevant passages before answering.</li>
<li><strong>LLM:</strong> reads the prompt and writes text or picks a tool. It can only use what fits in its context window. In simple terms, the context window is the amount of text the model can read at once.</li>
<li><strong>Agent or workflow control:</strong> decides the next step, enforces limits and stores state.</li>
<li><strong>Evaluation and operations:</strong> record traces, budgets and alerts, and test behaviour.</li>
</ul>
<p>Retrieval is <strong>one tool the agent can call</strong>, next to APIs and databases. If it returns the wrong chunks, every later step reasons from bad evidence.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> Think of a research assistant with a library built in advance. The pipeline stocks and indexes the shelves. The agent decides when to visit them, which other tools to use, and when to stop. The analogy stops at one point: the assistant is a language model, so it can misread a good source with confidence.</p>
</blockquote>
<h3>Which orchestration approach should you choose?</h3>
<p>Choose by <strong>failure semantics</strong> first: what happens on a crash, a timeout or bad tool output. Framework style comes second. The table below compares the four kinds of tooling, with facts dated 2026-10-17.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Kind</th>
<th>What it gives you</th>
<th>Dated facts</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Agent frameworks</strong></td>
<td>Control flow encoded as graphs or handoffs</td>
<td>LangGraph 1.2.16 (PyPI, Oct 16, 2026) has type-safe streaming, graceful shutdown and human-in-the-loop interrupts. Checkpoints use SQLite or memory for development and PostgreSQL for production. OpenAI Agents SDK is 0.23.1 (Oct 2, 2026), still 0.x, with the API changing between minor versions. Microsoft Agent Framework 1.0 reached GA on Apr 3, 2026 for .NET and Python, with MCP and A2A interoperability.</td>
</tr>
<tr>
<td><strong>Durable workflow engines</strong></td>
<td>Retries, checkpoints and crash recovery</td>
<td>Best when runs are long or side effects are costly.</td>
</tr>
<tr>
<td><strong>Managed agent platforms</strong></td>
<td>Hosted harness, sessions and sandboxes</td>
<td>OpenAI Agents API has been in public beta since Sep 10, 2026, with durable sessions, MCP servers and sandboxes.</td>
</tr>
<tr>
<td><strong>Plain code, no framework</strong></td>
<td>Full control, no dependency</td>
<td>Enough for a short fixed chain.</td>
</tr>
</tbody>
</table>
</figure>
<p>Pick the <strong>least autonomous architecture</strong> that meets the task. A short fixed chain does not need a framework. Pin exact versions in production, because LangGraph ships frequent patch releases and the OpenAI SDK is pre-1.0.</p>
<h2 id="agent-workflow-vs-autonomous-agent-choosing-patterns">Agent Workflow vs Autonomous Agent: Choosing Patterns</h2>
<p>Start with a single LLM call plus retrieval and examples. Move to a fixed workflow if that is not enough. Use an autonomous agent only when the steps cannot be known in advance.</p>
<p>Agentic systems usually trade latency and cost for better task performance, so each step up must earn its place. A study of production agents found that 16 of 20 case studies (80%) used predefined structured workflows, chosen for controllability and human oversight.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> A workflow is a recipe card: the steps are written down and the cook follows them. An agent is a chef who decides the next step after tasting the dish. The analogy stops there: the chef here is a language model, so it can be confidently wrong and needs limits and checks.</p>
</blockquote>
<h3>Which design should you choose?</h3>
<p>Apply these decision rules in order:</p>
<ol>
<li><strong>Steps known:</strong> use a fixed workflow, where your code decides the order.</li>
<li><strong>Path depends on what the model finds:</strong> use an agent, but only if the task value justifies the extra cost and latency.</li>
<li><strong>Mistake is costly or irreversible:</strong> add human approval gates before the risky action.</li>
</ol>
<p>The animated diagram below walks a new task through these questions.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/agentic-workflow-best-practices-diagram-4.gif" alt="Decision tree: a task passes four questions, ending at a single call, fixed workflow, rethink, agent with budgets, or agent with approval gate." width="1200" height="675" /></p>
<figcaption>Each step up from a single call must earn its extra latency and cost; irreversible actions get a human approval gate.</figcaption>
</figure>
<h3>Which agentic workflow design patterns exist?</h3>
<p>In simple terms, an orchestrator is a coordinator that splits a task and hands the parts to workers. This table compares the common agentic workflow design patterns.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Use when</th>
<th>Cost or latency trade-off</th>
<th>Main risk</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Prompt chaining</strong></td>
<td>A task splits into fixed sequential steps</td>
<td>One call per step, so latency adds up</td>
<td>An early error passes to later steps</td>
</tr>
<tr>
<td><strong>Routing</strong></td>
<td>Input types need different handling</td>
<td>One cheap classification call</td>
<td>Misrouted requests</td>
</tr>
<tr>
<td><strong>Parallelization</strong></td>
<td>Subtasks are independent</td>
<td>Lower latency, same or higher token cost</td>
<td>Merging conflicting results</td>
</tr>
<tr>
<td><strong>Orchestrator-workers</strong></td>
<td>Subtasks are not known until runtime</td>
<td>Extra planning call plus worker calls</td>
<td>Poor task splitting</td>
</tr>
<tr>
<td><strong>Evaluator-optimizer</strong></td>
<td>Clear quality criteria allow iterative improvement</td>
<td>Each round adds calls</td>
<td>Endless refinement without a round limit</td>
</tr>
<tr>
<td><strong>Plan-and-execute</strong></td>
<td>A plan can be written first, then run</td>
<td>One planning call, cheaper execution steps</td>
<td>A stale plan when reality changes</td>
</tr>
<tr>
<td><strong>Supervisor and workers</strong></td>
<td>Roles need different tools or permissions</td>
<td>Every handoff adds cost and delay</td>
<td>Unclear ownership between agents</td>
</tr>
</tbody>
</table>
</figure>
<h3>When should you split into multiple agents?</h3>
<p>Split only when roles need different tools, permissions or context. More agents multiply cost, latency and failure points, because every handoff is another place for a call to fail or for context to be lost. In multi-agent orchestration patterns, one agent with well-scoped tools is the default to beat.</p>
<p><strong>Use Case:</strong> A retail returns-triage workflow routes each request to refund, exchange or fraud review. The categories are known, so a router plus fixed branches is enough, and refunds above a set amount wait for human approval.</p>
<p><strong>Use Case:</strong> A finance research task uses orchestrator-workers. The orchestrator picks which filings and news to read based on the question, workers summarise each source, and an analyst reviews the draft before it is shared.</p>
<h2 id="build-the-agent-loop-state-limits-durability-and-approvals">Build the Agent Loop: State, Limits, Durability and Approvals</h2>
<p>An operable agent loop has an <strong>explicit state object</strong> (plan, observations, remaining budget), typed tool schemas, a retry policy, a loop limit, a per-step timeout, a cost budget and clear termination outcomes: succeeded, gave up or escalated. The CoreWeave production guide lists these as the minimum for knowing what an agent did and why it stopped. The same pieces are the base of <em>production AI agents checklist</em> items such as limits, retries and approvals.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> Think of a contractor with a work order: a task list, a spending cap, a deadline and a rule to phone the client before any big purchase. The analogy stops at trust. A language model can be confidently wrong, so your code enforces the cap, the deadline and the phone call. It does not rely on the model to respect them.</p>
</blockquote>
<p>The animated diagram below shows one pass of the loop, the state card that changes on every pass, and the three ways the loop can end.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/agentic-workflow-best-practices-diagram-5.gif" alt="Agent loop of plan, call tool, observe and check limits, with a state card, a retry path, and exits to success, dead-letter queue or human approval." width="1200" height="675" /></p>
<figcaption>Your code, not the model, enforces limits, retries and approvals; every pass is checkpointed and ends as succeeded, gave up or escalated.</figcaption>
</figure>
<h3>Which terms does the loop use?</h3>
<ul>
<li><strong>Agent state:</strong> In simple terms, it is the notebook the agent carries between steps: the goal, the plan, what it has seen and how much budget is left.</li>
<li><strong>Token:</strong> In simple terms, it is a small piece of text (a word or part of a word) that models read and write. Cost is usually counted in tokens.</li>
<li><strong>Temperature:</strong> In simple terms, it is a setting for how adventurous the model&#8217;s word choices are. Set it low for tool selection, because you want the same situation to give the same tool and well-formed arguments, not creative variation.</li>
<li><strong>Idempotency:</strong> In simple terms, doing the same action twice has the same effect as doing it once. Reading an order is idempotent. Charging a card is not, unless you send an idempotency key the payment service can recognise.</li>
<li><strong>Checkpoint:</strong> In simple terms, it is a saved copy of the state after a step, like a game save, so a crash resumes instead of restarting.</li>
<li><strong>Superstep:</strong> In simple terms, it is one round of a graph-based framework in which the ready steps run and the state is saved. LangGraph uses this term.</li>
</ul>
<h3>How do you set loop limits and budgets?</h3>
<p>The research gives no universal numbers for step limits, timeouts or cost caps, so treat every default as a placeholder to be tuned. Replay a set of real tasks and record the step-count distribution and the cost per successful run. Set the loop limit slightly above a high percentile of successful runs, then review every run that hits it. Those runs show whether the limit is too tight or the agent is genuinely stuck.</p>
<h3>How do you make the loop survive failures?</h3>
<p>Checkpoint after every step. Retry only idempotent calls, or writes that carry an idempotency key. Classify each error before deciding what to do:</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Error class</th>
<th>Example</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Retryable</strong></td>
<td>Timeout, dropped connection</td>
<td>Retry with backoff, up to a fixed count</td>
</tr>
<tr>
<td><strong>Fatal</strong></td>
<td>Bug, unknown tool, retries exhausted</td>
<td>Stop and give up</td>
</tr>
<tr>
<td><strong>Needs a human</strong></td>
<td>Permission denied, risky action declined</td>
<td>Escalate</td>
</tr>
</tbody>
</table>
</figure>
<p>Send exhausted tasks, with their saved state, to a <strong>dead-letter queue</strong>. In simple terms, that is a holding tray for jobs that failed, kept so a person can inspect and replay them. LangGraph&#8217;s graceful shutdown stops a run after the current superstep and saves a resumable checkpoint. Its checkpoint backends are in-memory or SQLite for development and PostgreSQL for production.</p>
<h3>How do you add human approval?</h3>
<p>For <em>human-in-the-loop AI agents</em>, interrupt before risky tools such as payments, deletes and outbound emails. Show the proposed arguments, then resume on approve or edit. Gate by risk level only, because approval fatigue is real: reviewers who see every action start approving without reading.</p>
<h3>Python example: a bounded agent loop</h3>
<p>Here&#8217;s a simplified example using the standard library and pydantic v2. The <code>call_llm</code> function is a stub you replace, and you can add custom logic for planning, logging and storage.</p>
<p class="code-label"><strong>Python</strong></p>
<pre><code class="language-python">import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable

from pydantic import BaseModel, Field, ValidationError


class Outcome(Enum):
    SUCCEEDED = "succeeded"
    GAVE_UP = "gave_up"
    ESCALATED = "escalated"


@dataclass
class AgentState:
    goal: str
    run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
    plan: list[str] = field(default_factory=list)
    observations: list[str] = field(default_factory=list)
    step: int = 0
    tokens_used: int = 0
    cost_usd: float = 0.0
    answer: str | None = None


class Decision(BaseModel):
    action: str  # "tool" or "finish"
    tool: str | None = None
    args: dict[str, Any] = Field(default_factory=dict)
    answer: str | None = None
    tokens: int = 0
    cost_usd: float = 0.0


class RefundArgs(BaseModel):
    order_id: str = Field(pattern=r"^ORD-\d{6}$")
    amount: float = Field(gt=0, le=500)


def issue_refund(args: RefundArgs, idempotency_key: str) -&gt; str:
    # Replace: send idempotency_key to the payment API so a retry cannot pay twice.
    return f"refund of {args.amount} queued for {args.order_id}"


@dataclass
class Tool:
    args_model: type[BaseModel]
    func: Callable[[Any, str], str]
    risky: bool = False
    idempotent: bool = False  # True = read-only, or a write that uses the key


RETRYABLE = (TimeoutError, ConnectionError)


def call_llm(state: AgentState) -&gt; Decision:
    # Stub: call your model here (low temperature) and return a Decision.
    raise NotImplementedError


def call_tool(tool: Tool, args: BaseModel, key: str, retries: int, deadline: float) -&gt; str:
    attempts = retries + 1 if tool.idempotent else 1
    for attempt in range(attempts):
        try:
            return tool.func(args, key)
        except RETRYABLE:
            if attempt == attempts - 1 or time.monotonic() &gt;= deadline:
                raise
            time.sleep(min(0.5 * 2**attempt, 5))
    raise RuntimeError("unreachable")


def run_agent(
    state: AgentState,
    tools: dict[str, Tool],
    approve: Callable[[str, BaseModel], BaseModel | None],
    save_checkpoint: Callable[[AgentState], None],
    *,
    max_steps: int = 8,         # placeholders: tune from replayed real tasks
    max_cost_usd: float = 0.50,
    timeout_s: float = 60.0,
    max_retries: int = 2,
) -&gt; Outcome:
    deadline = time.monotonic() + timeout_s
    while (state.step &lt; max_steps and state.cost_usd &lt; max_cost_usd
           and time.monotonic() &lt; deadline):
        decision = call_llm(state)
        state.step += 1
        state.tokens_used += decision.tokens
        state.cost_usd += decision.cost_usd

        if decision.action == "finish":
            state.answer = decision.answer
            save_checkpoint(state)
            return Outcome.SUCCEEDED

        tool = tools.get(decision.tool or "")
        if tool is None:
            state.observations.append(f"unknown tool: {decision.tool}")
        else:
            try:
                args = tool.args_model.model_validate(decision.args)
            except ValidationError as exc:
                state.observations.append(f"invalid arguments: {exc}")
            else:
                if tool.risky:
                    args = approve(decision.tool, args)  # approve, edit or decline
                    if args is None:
                        save_checkpoint(state)
                        return Outcome.ESCALATED
                key = f"{state.run_id}-{state.step}"  # stable across retries
                try:
                    state.observations.append(
                        call_tool(tool, args, key, max_retries, deadline))
                except PermissionError:
                    save_checkpoint(state)
                    return Outcome.ESCALATED
                except Exception as exc:  # fatal or retries exhausted
                    state.observations.append(f"tool failed: {exc}")
                    save_checkpoint(state)
                    return Outcome.GAVE_UP
        save_checkpoint(state)  # checkpoint after every step

    save_checkpoint(state)  # caller pushes this state to the dead-letter queue
    return Outcome.GAVE_UP
</code></pre>
<p><code>run_agent</code> returns an <code>Outcome</code> (SUCCEEDED, GAVE_UP or ESCALATED) and saves the state before every return, so a person or a replay job can pick up from the last step.</p>
<p>One limit to know: the loop checks the deadline between steps, so each tool function must enforce its own per-step timeout.</p>
<h3>How does this map to agent frameworks?</h3>
<p>The same loop appears in LangGraph, the OpenAI Agents SDK and Microsoft Agent Framework under different names. Your state object becomes the framework&#8217;s state or session, the checkpoint function becomes its persistence layer, and the approval callback becomes an interrupt or approval hook. Check each framework&#8217;s documentation for exact signatures. Pin versions, because the OpenAI SDK is still 0.x (0.23.1 on Oct 2, 2026) and LangGraph ships frequent patch releases (1.2.16 on Oct 16, 2026).</p>
<h2 id="secure-agentic-workflows-with-owasp-agentic-top-10-controls">Secure Agentic Workflows with OWASP Agentic Top 10 Controls</h2>
<p>Apply <strong>least agency</strong>. An agent gets only the autonomy, tools and credentials the task needs, and it earns more only after evaluation. Use the OWASP Top 10 for Agentic Applications 2026 (released Dec 9, 2025, ASI01 to ASI10) as your review checklist.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> Think of a temp worker given a key card that opens only the rooms needed today, and that stops working tonight. Unlike a human temp, an agent can be tricked by a sentence hidden in a document it reads. So the key card limits matter even more.</p>
</blockquote>
<h3>Which OWASP agentic risks should you check?</h3>
<p>Here are the ten risks in their published order, each with a typical symptom and a control.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>OWASP risk</th>
<th>What it looks like</th>
<th>Control</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Agent Goal Hijack</strong></td>
<td>A web page or email changes the agent&#8217;s objective</td>
<td>Treat retrieved content and tool output as data, never as instructions</td>
</tr>
<tr>
<td><strong>Tool Misuse</strong></td>
<td>A valid tool is called with harmful arguments</td>
<td>Scoped tools with parameter validation</td>
</tr>
<tr>
<td><strong>Identity and Privilege Abuse</strong></td>
<td>One broad key is reused across tasks</td>
<td>Short-lived, task-scoped credentials</td>
</tr>
<tr>
<td><strong>Agentic Supply Chain Vulnerabilities</strong></td>
<td>A compromised plugin or tool server</td>
<td>Pin versions, vet tool servers, record what you run</td>
</tr>
<tr>
<td><strong>Unexpected Code Execution</strong></td>
<td>Generated code runs on the host</td>
<td>Sandboxed execution</td>
</tr>
<tr>
<td><strong>Memory and Context Poisoning</strong></td>
<td>Bad facts saved to memory affect later runs</td>
<td>Validate what is stored; expire and audit memory</td>
</tr>
<tr>
<td><strong>Insecure Inter-Agent Communication</strong></td>
<td>One agent trusts another&#8217;s message blindly</td>
<td>Authenticate agents; validate messages like any input</td>
</tr>
<tr>
<td><strong>Cascading Failures</strong></td>
<td>One bad step spreads through many agents</td>
<td>Loop limits, budgets, kill switch</td>
</tr>
<tr>
<td><strong>Human-Agent Trust Exploitation</strong></td>
<td>A confident answer persuades a user to approve a bad action</td>
<td>Show proposed arguments; approval for sensitive actions</td>
</tr>
<tr>
<td><strong>Rogue Agents</strong></td>
<td>An agent drifts from its intended behaviour</td>
<td>Audit logs, monitoring, kill switch</td>
</tr>
</tbody>
</table>
</figure>
<p>In simple terms, a <strong>sandbox</strong> is a locked room where code can run without touching your real systems.</p>
<h3>How do MCP tool servers change the security picture?</h3>
<p>As of 2026-10-17, the current MCP revision is 2026-07-28 (the previous final one is 2025-11-25). In simple terms, MCP (Model Context Protocol) is a standard way for agents to discover and call tools on a server.</p>
<p>The new revision is <strong>stateless</strong>. The initialize handshake and the <code>Mcp-Session-Id</code> header are gone. Each request now carries its protocol version, client identity and capabilities in <code>_meta</code>. The revision also adds Multi Round-Trip Requests, header-based routing, cacheable list results, hardened authorization and an extensions framework.</p>
<ul>
<li><strong>Scale freely:</strong> servers can sit behind a load balancer without sticky sessions.</li>
<li><strong>Keep state out of the server:</strong> hold per-session data in your agent state, where you can checkpoint and audit it.</li>
<li><strong>Test before migrating:</strong> the C# SDK v2.0 implements this spec, but the Python, TypeScript, Go and C# SDKs are in beta.</li>
</ul>
<p><strong>Use Case:</strong> A healthcare scheduling agent gets a read-only calendar tool to find open slots. A separate booking tool sits behind human approval, which shows the patient, slot and clinician. Its credential is valid only for that task. Every call lands in the audit log.</p>
<h2 id="observe-evaluate-and-tune-agents-tracing-evals-cost-and-late">Observe, Evaluate and Tune Agents: Tracing, Evals, Cost and Latency</h2>
<p><a href="https://thecodebuzz.com/header-propagation-middleware-net-core/">Trace every run as one tree</a>, evaluate whole trajectories (the full sequence of steps, not just the final answer), and set your limits and model tiers from that data. This is the core of AI agent observability and guardrails.</p>
<h3>How do you trace an agent run?</h3>
<p>Record each run as one trace with a span per step. In simple terms, a <strong>span</strong> is a timed record of one action, and a <strong>trace</strong> is the tree of spans for one run. <strong>OpenTelemetry</strong> is an open standard for producing and exporting this data. Its GenAI conventions put an <code>invoke_agent</code> span at the top, with <code>chat</code> spans for model calls and <code>execute_tool</code> spans for tool calls beneath it.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> A trace is like a receipt that lists every task the assistant did, in order, with the time each took. Unlike a receipt, it can also show which step failed and why.</p>
</blockquote>
<p>The animated diagram below builds this tree step by step for a refund agent and sends the finished trace to the eval suite and alerts.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/agentic-workflow-best-practices-diagram-7.gif" alt="Trace tree for a refund agent: an invoke_agent root with chat and execute_tool spans, an approval pause, an outcome summary, then eval suite and alerts." width="1200" height="675" /></p>
<figcaption>Each step of a refund-agent run becomes a span under one invoke_agent root, and the finished trace feeds the eval suite and alerts.</figcaption>
</figure>
<p>Per step, record tokens, cost, latency, retry count and the final termination outcome. Prompt and response content is opt-in, because it can contain personal data. The spec is still marked Development, so attribute names can change. Keep them in one helper.</p>
<p class="code-label"><strong>C#</strong></p>
<pre><code class="language-csharp">using System.Diagnostics;

// Simplified helper. Attribute names may change while the GenAI spec is in Development.
public static class AgentTrace
{
    private static readonly ActivitySource Source = new("TheCodeBuzz.Agents");

    public static Task&lt;T&gt; AgentAsync&lt;T&gt;(string agent, Func&lt;Task&lt;T&gt;&gt; body) =&gt;
        Run($"invoke_agent {agent}", "invoke_agent", "gen_ai.agent.name", agent, body);

    public static Task&lt;T&gt; ChatAsync&lt;T&gt;(string model, Func&lt;Task&lt;T&gt;&gt; body) =&gt;
        Run($"chat {model}", "chat", "gen_ai.request.model", model, body);

    public static Task&lt;T&gt; ToolAsync&lt;T&gt;(string tool, Func&lt;Task&lt;T&gt;&gt; body) =&gt;
        Run($"execute_tool {tool}", "execute_tool", "gen_ai.tool.name", tool, body);

    public static void RecordUsage(int inputTokens, int outputTokens)
    {
        Activity.Current?.SetTag("gen_ai.usage.input_tokens", inputTokens);
        Activity.Current?.SetTag("gen_ai.usage.output_tokens", outputTokens);
    }

    private static async Task&lt;T&gt; Run&lt;T&gt;(string name, string operation,
        string key, string value, Func&lt;Task&lt;T&gt;&gt; body)
    {
        using var activity = Source.StartActivity(name);
        activity?.SetTag("gen_ai.operation.name", operation);
        activity?.SetTag(key, value);
        try
        {
            return await body();
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            activity?.SetTag("error.type", ex.GetType().FullName);
            throw;
        }
    }
}</code></pre>
<p>Nested calls become child spans automatically, because <code>Activity.Current</code> flows through async code. Register the source with <code>AddSource("TheCodeBuzz.Agents")</code> in OpenTelemetry for .NET, otherwise <code>StartActivity</code> returns null and nothing is exported.</p>
<h3>How do you evaluate an agent?</h3>
<p>Build a regression suite from real tasks and score the trajectory, not only the answer. A <strong>trajectory evaluation</strong> checks the path the agent took. A <strong>regression suite</strong> is a fixed set of tests you rerun after every change.</p>
<ul>
<li><strong>Trajectory checks:</strong> right tool, right arguments, no forbidden calls, step count and cost.</li>
<li><strong>Answer quality:</strong> score the final output separately.</li>
<li><strong>Rerun on any change:</strong> prompt, tool schema or model, because they interact.</li>
<li><strong>Adversarial cases:</strong> include prompt injection hidden in tool output.</li>
<li><strong>Two environments:</strong> keep offline evals apart from sampled production evals.</li>
</ul>
<h3>How do you control cost and latency?</h3>
<p>Pick the controls below and check each against your eval suite. Every one trades something for speed or savings.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Control</th>
<th>Benefit</th>
<th>Trade-off</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Token budget per run</strong></td>
<td>Caps worst-case spend</td>
<td>Hard tasks may stop early</td>
</tr>
<tr>
<td><strong>Model tiering</strong></td>
<td>Small model for routing and extraction, larger for hard planning</td>
<td>Routing mistakes reach the weaker model</td>
</tr>
<tr>
<td><strong>Caching</strong></td>
<td>Stable prompt prefixes and cacheable MCP list results</td>
<td>Stale data if the prefix or list changes</td>
</tr>
<tr>
<td><strong>Context compression</strong></td>
<td>Summarise old steps to shrink input</td>
<td>Detail can be lost</td>
</tr>
<tr>
<td><strong>Parallel tool calls</strong></td>
<td>Lower latency for independent steps</td>
<td>Harder error handling</td>
</tr>
<tr>
<td><strong>Streaming</strong></td>
<td>Users see partial output sooner</td>
<td>Early text may be revised</td>
</tr>
</tbody>
</table>
</figure>
<p>To tune tiers, plot cost per successful task against success rate for each tier. Choose the knee of the curve, where extra spend stops buying much accuracy.</p>
<h3>Which alerts should you set?</h3>
<p>Alert on loop-limit hits, escalation rate, cost per run and tool error rate. Review every run that hits a limit, since that shows whether the limit or the task design is wrong. These signals also belong on any production AI agents checklist.</p>
<h2 id="common-anti-patterns-in-agentic-workflows">Common Anti-Patterns in Agentic Workflows</h2>
<p>Most failures in production AI agents come from a small set of repeated design mistakes. The table below lists nine of them with the symptom you will see and the fix.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> An anti-pattern is a habit that looks sensible at first and causes trouble later, like a manager who gives a new assistant the master key, no deadline and no instructions to report back. The analogy stops at one point: a human assistant would usually push back, while a language model will keep going.</p>
</blockquote>
<h3>Which Agentic Workflow Mistakes Should You Avoid?</h3>
<p>Use this table as a review list for agentic AI workflow architecture and for any production AI agents checklist.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Anti-pattern</th>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Agent used where a fixed workflow would do</strong></td>
<td>Slow, costly runs with varying paths for a known task</td>
<td>Use a workflow when the steps are known (see agent workflow vs autonomous agent)</td>
</tr>
<tr>
<td><strong>No loop limit or budget</strong></td>
<td>Runs that never end; surprise bills</td>
<td>Set max steps, a deadline and a cost budget</td>
</tr>
<tr>
<td><strong>Free-text tool arguments</strong></td>
<td>Malformed calls, wrong values reach the tool</td>
<td>Use typed schemas and validate before executing</td>
</tr>
<tr>
<td><strong>Retrying non-idempotent writes</strong></td>
<td>Duplicate refunds, emails or bookings</td>
<td>Retry only idempotent calls, or send idempotency keys</td>
</tr>
<tr>
<td><strong>State only in the prompt</strong></td>
<td>A crash restarts the task; context grows without control</td>
<td>Keep an explicit state object and checkpoint each step</td>
</tr>
<tr>
<td><strong>Trusting tool output or retrieved text as instructions</strong></td>
<td>The agent follows commands hidden in a web page or document</td>
<td>Treat it as data only; add injection tests</td>
</tr>
<tr>
<td><strong>Broad, long-lived credentials</strong></td>
<td>One bug or hijack exposes every system</td>
<td>Use short-lived, task-scoped credentials</td>
</tr>
<tr>
<td><strong>Evaluating only the final answer</strong></td>
<td>Right answer, but forbidden calls or wasted steps go unseen</td>
<td>Score the whole trajectory: tools, arguments, step count, cost</td>
</tr>
<tr>
<td><strong>Floating versions on a 0.x SDK</strong></td>
<td>An upgrade silently changes behavior</td>
<td>Pin exact versions and re-run the regression suite before upgrading</td>
</tr>
<tr>
<td><strong>Many agents with unclear roles</strong></td>
<td>Handoff loops, higher cost, hard debugging</td>
<td>Split only when roles need different tools, permissions or context</td>
</tr>
</tbody>
</table>
</figure>
<p>For multi-agent orchestration patterns, human-in-the-loop AI agents and AI agent observability and guardrails, see the sections that cover each in detail.</p>
<h2 id="production-readiness-checklist-for-ai-agents">Production Readiness Checklist for AI Agents</h2>
<p>Use this production AI agents checklist before you release an agent to real users. It runs from design to operations. Every item can be verified with a test, a config value or a dashboard.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>Tip:</strong> Think of the pre-flight checklist a pilot runs before takeoff. Each line is a quick, checkable fact, not an opinion. The analogy stops in one place: an agent&#8217;s checklist must be run again whenever the prompt, a tool or the model changes.</p>
</blockquote>
<p>Work through these items in order:</p>
<ol>
<li><strong>Architecture choice:</strong> Write down why a single LLM call or fixed workflow is not enough. If you can&#8217;t, don&#8217;t build an agent.</li>
<li><strong>Outcomes:</strong> Define succeeded, gave up and escalated, plus the conditions that end a run.</li>
<li><strong>Tools:</strong> Give every tool a typed schema. Validate arguments before the tool runs.</li>
<li><strong>State:</strong> Keep an explicit state object and checkpoint after every step.</li>
<li><strong>Limits:</strong> Set step, time and cost limits from replayed eval data, not guesses.</li>
<li><strong>Retries:</strong> Retry only idempotent calls. Send exhausted tasks to a dead-letter queue with their saved state.</li>
<li><strong>Approvals:</strong> Add human-in-the-loop AI agent gates on payments, deletes and outbound messages.</li>
<li><strong>Least agency:</strong> Use short-lived, <a href="https://thecodebuzz.com/secure-secrets-azure-key-vault-examples-csharp-java-python-nodejs/">task-scoped credentials</a> and sandboxed code, mapped to each OWASP agentic risk.</li>
<li><strong>Tracing:</strong> Trace each run as one tree. Keep prompt and response capture off by default.</li>
<li><strong>Regression suite:</strong> Run trajectory evals in CI on every prompt, schema or model change, including prompt injection cases that arrive through tool output.</li>
<li><strong>Versions:</strong> Pin dependencies and record the MCP spec version you tested against.</li>
<li><strong>Operations:</strong> Set alerts for loop-limit hits, escalation rate, cost per run and tool errors. Rehearse the kill switch and the rollback plan before you need them.</li>
</ol>
<h2 id="glossary-in-plain-english">Glossary in Plain English</h2>
<p>This glossary lists the terms used in this article on agentic AI workflow architecture, grouped by topic. Each analogy comes with a note where it stops being accurate.</p>
<blockquote class="tcb-plain-english" style="border-left: 4px solid #f59e0b; background: #fffbeb; margin: 18px 0; padding: 10px 16px; font-style: normal;">
<p><strong>In plain English:</strong> Treat the analogies as a phrasebook, not a specification. They help you picture an idea, and the third column says where each picture stops matching the real thing.</p>
</blockquote>
<h3>Models and Cost Terms</h3>
<p>These terms describe the model and what it costs to run.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Term</th>
<th>What it means in plain English</th>
<th>Everyday analogy</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Agentic workflow</strong></td>
<td>A program where a language model and tools work through a task in steps. Either code or the model chooses the steps.</td>
<td>An assistant working down a to-do list. Unlike a person, the model can be confidently wrong, so it needs checks.</td>
</tr>
<tr>
<td><strong>Agent</strong></td>
<td>A setup where the model itself decides the next step and which tool to use.</td>
<td>A contractor who plans the job. Unlike a contractor, it needs a hard budget and time limit.</td>
</tr>
<tr>
<td><strong>LLM</strong></td>
<td>Large language model: software that produces text based on patterns learned in training.</td>
<td>A very well-read autocomplete. It does not look facts up unless you give them to it.</td>
</tr>
<tr>
<td><strong>Tool call</strong></td>
<td>A structured request from the model asking your code to run a function.</td>
<td>An order slip passed to a kitchen. The model writes the slip, and your code does the cooking.</td>
</tr>
<tr>
<td><strong>Token</strong></td>
<td>A small piece of text (a word or part of a word) that models read and bill by.</td>
<td>Words in a telegram, priced per piece. Tokens do not map exactly to words.</td>
</tr>
<tr>
<td><strong>Context window</strong></td>
<td>The maximum number of tokens the model can consider at once.</td>
<td>A desk: only so many papers fit. Anything off the desk is not seen at all.</td>
</tr>
<tr>
<td><strong>Temperature</strong></td>
<td>A setting for how random the output is. Low values give steadier answers.</td>
<td>A dial from strict clerk to brainstorming partner. Low does not mean correct, only repeatable.</td>
</tr>
<tr>
<td><strong>Hallucination</strong></td>
<td>A confident answer that is false or invented.</td>
<td>Someone bluffing in a meeting. The model has no intent to deceive.</td>
</tr>
<tr>
<td><strong>Model tiering</strong></td>
<td>Using a small, cheap model for easy steps and a larger one for hard steps.</td>
<td>A triage nurse and a specialist. Routing mistakes by the nurse still reach the patient.</td>
</tr>
</tbody>
</table>
</figure>
<h3>Knowledge and Retrieval Terms</h3>
<p>These terms cover how knowledge gets to the agent.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Term</th>
<th>What it means in plain English</th>
<th>Everyday analogy</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Chunk</strong></td>
<td>A small piece of a longer document, cut so it can be searched and passed to the model.</td>
<td>An index card from a book. A bad cut can separate a fact from its context.</td>
</tr>
<tr>
<td><strong>Embedding</strong></td>
<td>A list of numbers that represents the meaning of a text.</td>
<td>A GPS coordinate on a map of meaning. The real map has hundreds of dimensions, not two.</td>
</tr>
<tr>
<td><strong>Vector store</strong></td>
<td>A database that finds items whose embeddings are closest to a query.</td>
<td>A library shelved by topic instead of title. &#8220;Close&#8221; means similar, not necessarily correct.</td>
</tr>
<tr>
<td><strong>Retrieval</strong></td>
<td>Fetching the most relevant chunks for a question.</td>
<td>A librarian pulling cards. If the wrong cards come back, the answer suffers.</td>
</tr>
</tbody>
</table>
</figure>
<h3>Loop and Reliability Terms</h3>
<p>These terms describe how the loop keeps running and recovers from failure.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Term</th>
<th>What it means in plain English</th>
<th>Everyday analogy</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Agent state</strong></td>
<td>The record of the plan, results so far and remaining budget.</td>
<td>A case file. It only helps if the agent actually reads and updates it.</td>
</tr>
<tr>
<td><strong>Checkpoint</strong></td>
<td>A saved copy of the state after a step, so a crashed run can resume.</td>
<td>A video-game save. It also stores tool results, which may have changed in the outside world since.</td>
</tr>
<tr>
<td><strong>Superstep</strong></td>
<td>One round of a graph workflow (a LangGraph term) where ready steps run, then state is saved.</td>
<td>A board-game turn where everyone moves, then the board updates.</td>
</tr>
<tr>
<td><strong>Idempotency</strong></td>
<td>Repeating an action has the same effect as doing it once.</td>
<td>Pressing a lift button twice. Sending an email is not idempotent unless you add an idempotency key.</td>
</tr>
<tr>
<td><strong>Dead-letter queue</strong></td>
<td>A holding area for tasks that failed every retry, kept with their saved state.</td>
<td>The undeliverable-mail room. Someone still has to open the items.</td>
</tr>
<tr>
<td><strong>Human-in-the-loop</strong></td>
<td>A person approves or edits a risky step before it runs.</td>
<td>A countersigned cheque. Too many requests lead to rubber-stamping.</td>
</tr>
<tr>
<td><strong>Orchestration</strong></td>
<td>The control logic that sequences steps, tools, retries and limits.</td>
<td>A conductor. The conductor does not play any instrument.</td>
</tr>
<tr>
<td><strong>MCP</strong></td>
<td>Model Context Protocol: a standard way for agents to talk to tool servers.</td>
<td>A USB port. The plug fits, but you still choose what to plug in.</td>
</tr>
<tr>
<td><strong>Stateless</strong></td>
<td>The server keeps nothing between requests, so each request carries what it needs.</td>
<td>A cashier who forgets you after each sale, so you bring your own receipt.</td>
</tr>
</tbody>
</table>
</figure>
<h3>Security and Observability Terms</h3>
<p>These terms cover protection, monitoring and testing.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Term</th>
<th>What it means in plain English</th>
<th>Everyday analogy</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Prompt injection</strong></td>
<td>Hidden instructions inside text the model reads, meant to change its behaviour.</td>
<td>A forged note slipped into an assistant&#8217;s inbox saying &#8220;ignore your boss&#8221;.</td>
</tr>
<tr>
<td><strong>Least agency</strong></td>
<td>Giving an agent only the autonomy and access its task needs.</td>
<td>A hotel keycard that opens only your room.</td>
</tr>
<tr>
<td><strong>Sandbox</strong></td>
<td>An isolated area where generated code runs without touching real systems.</td>
<td>A test kitchen. A badly configured one can still leak.</td>
</tr>
<tr>
<td><strong>Span / trace</strong></td>
<td>A span is one timed step. A trace is the full tree of spans for one run.</td>
<td>Parcel tracking: each scan is a span, and the whole history is the trace.</td>
</tr>
<tr>
<td><strong>OpenTelemetry</strong></td>
<td>An open standard for emitting traces and metrics to monitoring tools.</td>
<td>A common plug shape for monitoring equipment.</td>
</tr>
<tr>
<td><strong>Trajectory evaluation</strong></td>
<td>Scoring the path an agent took (tools, arguments, steps, cost), not only its final answer.</td>
<td>Marking the working in a maths exam, not just the result.</td>
</tr>
<tr>
<td><strong>Regression suite</strong></td>
<td>A fixed set of real tasks re-run after every change to catch new failures.</td>
<td>A pre-flight checklist. Passing it does not cover cases it never listed.</td>
</tr>
</tbody>
</table>
</figure>
<h2 id="key-takeaways">Key Takeaways</h2>
<p>These are the points to keep from the article, ordered from design decisions to operations.</p>
<ul>
<li><strong>Start with the simplest design:</strong> try a single LLM call with retrieval and examples first, then a fixed workflow. Use an autonomous agent only when the steps cannot be known in advance, because agents usually cost more in latency and money.</li>
<li><strong>Workflows dominate production:</strong> in the study cited earlier, 16 of 20 case studies (80%) used predefined structured workflows for control and human oversight. If code can decide the steps, let code decide them.</li>
<li><strong>Make the loop explicit and bounded:</strong> keep an explicit state object, typed and validated tool schemas, and limits for steps, time and cost. Return a clear outcome (succeeded, gave up or escalated). Set the limits from replayed real tasks, not from a universal number.</li>
<li><strong>Design for crashes and retries:</strong> checkpoint after every step, retry only idempotent calls (or use idempotency keys for writes), and send exhausted tasks to a dead-letter queue with the saved state. Choose tooling by failure semantics before framework style.</li>
<li><strong>Apply least agency and the OWASP list:</strong> give agents short-lived, task-scoped credentials and scoped tools. Treat retrieved content and tool output as untrusted data, sandbox code execution, and gate risky actions by risk level. Keep a kill switch and audit logs.</li>
<li><strong>Trace and evaluate trajectories:</strong> record each run as one trace tree and score the tools, arguments, step count and cost, not only the final answer. Re-run the regression suite whenever the prompt, tool schema or model changes.</li>
<li><strong>Pin versions and re-check dated facts:</strong> LangGraph patch releases are frequent, the OpenAI Agents SDK is still 0.x, and the OpenTelemetry GenAI conventions are still in Development. Record the MCP spec revision you target, and re-verify the version facts dated 2026-10-17 before you rely on them.</li>
</ul>
<p>That&#8217;s all! Happy coding!</p>
<p>Did this solve your problem? Please share your questions or experience in the comments below. Bookmark this page or share it with your team, and subscribe to TheCodeBuzz for more AI engineering best practices.</p>
<h2 id="faq">FAQ</h2>
<h3>What is an agentic workflow and how is it different from an AI agent?</h3>
<p>An agentic workflow is a process where code sets the steps and a language model does the work inside them (e.g. classify, look up, draft). An AI agent is different because the model itself decides which step or tool comes next. Most production systems use workflows for this reason, since a fixed path is easier to test, limit and audit. Many real systems mix both, with a fixed outer flow and one agent loop for a single open-ended step.</p>
<h3>When should I use a fixed workflow instead of an autonomous agent?</h3>
<p>Use a fixed workflow whenever you can write down the steps before the task starts. Start with a single model call plus retrieval and examples, move to a workflow if that is not enough, and use an agent only when the path depends on what the model finds along the way. Agents usually cost more and respond more slowly, so the task value must justify that. If a wrong action is expensive, add an approval gate whichever design you pick.</p>
<h3>How do I control cost and latency in an agent loop?</h3>
<p>Set hard limits first: a maximum step count, a per-step timeout, a deadline and a token or cost budget per run. Then reduce spend with model tiering (a small model for routing and extraction, a larger one for hard planning), caching of stable prompt prefixes, summarising old steps, and parallel calls for independent tools. Summarising can drop detail, so check it against your regression suite. Tune the numbers from real traces by comparing cost per successful task with success rate for each tier.</p>
<h3>How do I add human-in-the-loop approval to an agent?</h3>
<p>Pause the loop before any risky tool call, such as a payment, a delete or an outbound email, and show the reviewer the exact proposed arguments. Save the agent state at that point so the run can resume after the person approves, edits or rejects the action. Frameworks such as LangGraph offer interrupts for this, and the same idea works in plain code with an approval callback. Gate by risk level only, because asking for approval on every step leads to reviewers clicking through without reading.</p>
<h3>What are the biggest security risks for agentic applications, and how do I reduce them?</h3>
<p>The OWASP Top 10 for Agentic Applications 2026 is the best checklist, and its risks include agent goal hijack, tool misuse, identity and privilege abuse, and memory and context poisoning. The guiding rule is least agency: give the agent only the autonomy and access it has earned. In practice, use short-lived credentials scoped to the task, validate tool parameters, run generated code in a sandbox, and treat retrieved content and tool output as untrusted data, never as instructions. Add approval for sensitive actions, audit logs and a kill switch.</p>
<h2 id="sources">Sources and further reading</h2>
<ul>
<li><a href="https://www.anthropic.com/engineering/building-effective-agents" target="_blank" rel="noopener">Building Effective AI Agents</a> (Anthropic Engineering)</li>
<li><a href="https://arxiv.org/pdf/2512.04123" target="_blank" rel="noopener">Measuring Agents in Production</a> (arXiv)</li>
<li><a href="https://wf.coreweave.com/blog/agentic-workflows-in-production-mechanisms-trade-offs-costs-and-design-patterns" target="_blank" rel="noopener">Agentic workflows in production: Mechanisms, costs and trade-offs</a> (CoreWeave)</li>
<li><a href="https://genai.owasp.org/2025/12/09/owasp-top-10-for-agentic-applications-the-benchmark-for-agentic-security-in-the-age-of-autonomous-ai/" target="_blank" rel="noopener">OWASP Top 10 for Agentic Applications &#8211; The Benchmark for Agentic Security in the Age of Autonomous AI</a> (OWASP Gen AI Security Project)</li>
<li><a href="https://auth0.com/blog/owasp-top-10-agentic-applications-lessons/" target="_blank" rel="noopener">Lessons from OWASP Top 10 for Agentic Applications</a> (Auth0)</li>
<li><a href="https://pypi.org/project/langgraph/" target="_blank" rel="noopener">langgraph · PyPI</a> (PyPI)</li>
<li><a href="https://www.jbinternational.co.uk/article/view/4680" target="_blank" rel="noopener">LangGraph 1.0 and 1.2 in 2026: Why Durable State Has Changed Everything for Python Agent Developers</a> (JBI Training)</li>
<li><a href="https://github.com/openai/openai-agents-python/releases" target="_blank" rel="noopener">Releases · openai/openai-agents-python</a> (GitHub (OpenAI))</li>
<li><a href="https://developers.openai.com/api/docs/changelog" target="_blank" rel="noopener">Changelog</a> (OpenAI)</li>
<li><a href="https://visualstudiomagazine.com/articles/2026/04/06/microsoft-ships-production-ready-agent-framework-1-0-for-net-and-python.aspx" target="_blank" rel="noopener">Microsoft Ships Production-Ready Agent Framework 1.0 for .NET and Python</a> (Visual Studio Magazine)</li>
</ul>
<p>&nbsp;</p>


<p class="wp-block-paragraph">Please share this article with your friends and <a rel="noreferrer noopener" href="https://www.thecodebuzz.com/subscription/" target="_blank"><em><strong>Subscribe</strong> </em></a>to the blog to get a notification on freshly published best practices of software development.</p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph">        </p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph"></p><p>The post <a href="https://thecodebuzz.com/agentic-workflow-best-practices/">Agentic Workflow Best Practices: Architecture and Checklist</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://thecodebuzz.com/agentic-workflow-best-practices/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server Best Practices and Guidelines-2026</title>
		<link>https://thecodebuzz.com/sql-server-best-practices-guidelines/</link>
					<comments>https://thecodebuzz.com/sql-server-best-practices-guidelines/#respond</comments>
		
		<dc:creator><![CDATA[Izzy]]></dc:creator>
		<pubDate>Tue, 13 Oct 2026 21:38:05 +0000</pubDate>
				<category><![CDATA[Database Best Practices]]></category>
		<category><![CDATA[DBA]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[SQL Server 2025]]></category>
		<category><![CDATA[T-SQL]]></category>
		<guid isPermaLink="false">https://thecodebuzz.com/?p=30819</guid>

					<description><![CDATA[<p>SQL Server best practices for 2022 and 2025: memory, MAXDOP, tempdb, security, backups and upgrades, with runnable T-SQL and C# examples.</p>
<p>The post <a href="https://thecodebuzz.com/sql-server-best-practices-guidelines/">SQL Server Best Practices and Guidelines-2026</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>This guide is a bookmarkable reference for developers and DBAs who run SQL Server in production. It is current for SQL Server 2022 and 2025, with runnable T-SQL, C# and Python examples. Many older checklists still repeat the &#8220;leave 4-6 GB or 10-15% for the OS&#8221; memory rule and give contradictory MAXDOP advice. They also skip the 2025 features, the current driver defaults and the upgrade pitfalls. These SQL Server best practices fix that.</p>
<p>It is different from the existing post, <em>SQL Server Best Practices for Developers and DBAs (2026 Guide)</em>. This one is organised by production concern: configuration, indexing and queries, transactions, drivers, security, backups, monitoring and upgrades. Each rule explains the why, states the trade-off and gives a command to verify the result. The guide ends with an anti-patterns table and a production readiness checklist.</p>
<p>Before you start, you need sysadmin access on a test instance and SSMS or sqlcmd. The .NET examples use <a href="https://thecodebuzz.com/connect-sql-server-database-insert-update-delete-sqlclient-csharp/">Microsoft.Data.SqlClient</a> and the Python examples use pyodbc. Support dates, build numbers and default behaviours are correct as of October 2026. Always test every change on a non-production copy first.</p>
<blockquote><p><strong>Version note:</strong> This guide targets SQL Server 2022 (latest CU27, 16.0.4295.3) and SQL Server 2025 (latest CU9, 17.0.5005.3), with Microsoft.Data.SqlClient v7.0.1.</p>
<p>&nbsp;</p></blockquote>
<p><strong>In this guide</strong></p>
<ul>
<li><a href="#sql-server-best-practices-at-a-glance">SQL Server Best Practices at a Glance</a></li>
<li><a href="#sql-server-configuration-best-practices-memory-maxdop-cost-t">SQL Server Configuration Best Practices: Memory, MAXDOP, Cost Threshold and tempdb</a></li>
<li><a href="#data-modeling-and-indexing-best-practices">Data Modeling and Indexing Best Practices</a></li>
<li><a href="#query-optimization-and-sql-server-performance-tuning-best-pr">Query Optimization and SQL Server Performance Tuning Best Practices</a></li>
<li><a href="#transactions-isolation-levels-and-optimized-locking">Transactions, Isolation Levels and Optimized Locking</a></li>
<li><a href="#connection-management-and-microsoft-data-sqlclient-best-prac">Connection Management and Microsoft.Data.SqlClient Best Practices</a></li>
<li><a href="#sql-server-security-best-practices-least-privilege-injection">SQL Server Security Best Practices: Least Privilege, Injection and Encryption</a></li>
<li><a href="#sql-server-backup-best-practices-high-availability-and-resto">SQL Server Backup Best Practices, High Availability and Restore Testing</a></li>
<li><a href="#monitoring-sql-server-wait-stats-extended-events-and-perform">Monitoring SQL Server: Wait Stats, Extended Events and Performance Counters</a></li>
<li><a href="#patching-and-upgrading-to-sql-server-2025-what-to-check-firs">Patching and Upgrading to SQL Server 2025: What to Check First</a></li>
<li><a href="#common-anti-patterns-and-sql-server-dba-production-readiness">Common Anti-Patterns and SQL Server DBA Production Readiness Checklist</a></li>
<li><a href="#key-takeaways">Key takeaways</a></li>
<li><a href="#faq">FAQ</a></li>
</ul>
<p>&nbsp;</p>
<h2 id="sql-server-best-practices-at-a-glance">SQL Server Best Practices at a Glance</h2>
<p>&nbsp;</p>
<p>This guide covers production best practices for SQL Server 2022 and SQL Server 2025, from instance configuration and indexing to security, backups, monitoring and upgrades.</p>
<p>&nbsp;</p>
<p>The table below is a quick reference for developers, architects and DBAs. Each rule points to the section that explains the reason, so you can jump to the detail when you need it.</p>
<p>&nbsp;</p>
<h3>Quick Reference Table</h3>
<p>&nbsp;</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Area</th>
<th>Do this</th>
<th>Avoid this</th>
</tr>
</thead>
<tbody>
<tr>
<td>Instance memory</td>
<td>Cap <code>max server memory</code> at about 75% of available memory (see Configuration).</td>
<td>Leaving the default of about 2 TB.</td>
</tr>
<tr>
<td>MAXDOP and cost threshold</td>
<td>Start from the NUMA-based MAXDOP rule and tune cost threshold by measurement (see Configuration).</td>
<td>MAXDOP 1 server-wide, or cost threshold left at 5.</td>
</tr>
<tr>
<td>tempdb</td>
<td>Use equally sized, pre-sized data files with fixed growth (see Configuration).</td>
<td>One small file that relies on autogrow.</td>
</tr>
<tr>
<td>Storage layout</td>
<td>Separate data, log and tempdb volumes; enable instant file initialization (see Configuration).</td>
<td>Putting everything on one shared volume.</td>
</tr>
<tr>
<td>Data types and keys</td>
<td>Use the narrowest correct type and a narrow, unique, ever-increasing clustered key (see Data Modeling).</td>
<td>Random GUID clustered keys and <code>nvarchar(max)</code> for short values.</td>
</tr>
<tr>
<td>Indexing</td>
<td>Index for WHERE, JOIN and ORDER BY, and use INCLUDE columns (see Data Modeling).</td>
<td>Duplicate and unused indexes.</td>
</tr>
<tr>
<td>Query patterns</td>
<td>Write SARGable predicates and set-based code (see Query Optimization).</td>
<td><code>SELECT *</code> and functions on indexed columns.</td>
</tr>
<tr>
<td>Isolation level</td>
<td>Use RCSI for read-heavy OLTP after testing (see Transactions).</td>
<td>Long transactions and untested isolation changes.</td>
</tr>
<tr>
<td>Connection strings</td>
<td>Keep <code>Encrypt=Mandatory</code> or use Strict (see Connection Management).</td>
<td><code>TrustServerCertificate=true</code> in production.</td>
</tr>
<tr>
<td>Authentication and permissions</td>
<td>Use Windows or Entra auth and a custom role (see Security).</td>
<td>Using <code>sa</code> or <code>db_owner</code> for apps.</td>
</tr>
<tr>
<td>Dynamic SQL</td>
<td>Use <code>sp_executesql</code> with typed parameters (see Security).</td>
<td>Concatenating user input into SQL strings.</td>
</tr>
<tr>
<td>Backups</td>
<td>Use <code>CHECKSUM</code>, compression and off-server copies (see Backups).</td>
<td>FULL recovery with no log backups.</td>
</tr>
<tr>
<td>Restore testing</td>
<td>Restore on a schedule and run <code>DBCC CHECKDB</code> (see Backups).</td>
<td>Trusting <code>RESTORE VERIFYONLY</code> alone.</td>
</tr>
<tr>
<td>Patching</td>
<td>Patch on a cadence and test on a copy first (see Patching and Upgrading).</td>
<td>Switching between CU and GDR tracks casually.</td>
</tr>
<tr>
<td>Monitoring</td>
<td>Start with wait stats, then Query Store and Extended Events (see Monitoring).</td>
<td>Alerting only on whether a job ran.</td>
</tr>
</tbody>
</table>
</figure>
<h3></h3>
<h3>How to Read This Guide</h3>
<p>&nbsp;</p>
<p>Treat every rule as a default, not a law. Where a rule does not apply, the section states the trade-off and the case where you should do something different.</p>
<p>&nbsp;</p>
<p>Test each change on a non-production copy and measure before and after.</p>
<p>&nbsp;</p>
<h2 id="sql-server-configuration-best-practices-memory-maxdop-cost-t">SQL Server Configuration Best Practices: Memory, MAXDOP, Cost Threshold and tempdb</h2>
<p>&nbsp;</p>
<p>These instance-level settings decide how SQL Server uses memory, CPU and temporary storage. Most of them ship with defaults that suit a demo machine, not a production server, so review them on every new install, on 2022 and on 2025.</p>
<p>&nbsp;</p>
<h3>SQL Server Max Server Memory Setting</h3>
<p>&nbsp;</p>
<p>Cap max server memory on every version. The default is about 2 TB, which means SQL Server will take whatever it can and leave the OS and other processes to fight for the rest.</p>
<p>Current Microsoft docs suggest a starting point of about 75% of the available memory not used by other processes, including other instances on the same host. Leave min server memory at 0. Setup on Windows has suggested a value since SQL Server 2019, so check what it picked.</p>
<p>&nbsp;</p>
<p>The older advice was to leave 4-6 GB, or 10-15%, for the OS. A percentage of what is actually available is a better baseline because it already subtracts a second instance, an SSIS service, antivirus or a monitoring agent. A fixed reserve ignores all of them.</p>
<p>&nbsp;</p>
<p>If Lock Pages in Memory (LPIM) is granted to the service account, set a specific value. LPIM stops Windows from trimming SQL Server&#8217;s memory, so a wrong cap can starve the OS.</p>
<p>&nbsp;</p>
<h3>MAXDOP: Why OLTP Does Not Automatically Mean MAXDOP</h3>
<p>&nbsp;</p>
<p>MAXDOP (maximum degree of parallelism) is the number of CPUs one query can use. Some teams say &#8220;OLTP means MAXDOP 1&#8221;, while Microsoft publishes a NUMA-based rule. They conflict because the first is a workaround for parallelism waits and the second is a general default.</p>
<p>&nbsp;</p>
<p>Start from the Microsoft guidance. A NUMA node (Non-Uniform Memory Access node) is a group of processors that share local memory. Keeping a query inside one node avoids slower cross-node memory access. The table below shows the starting values.</p>
<p>&nbsp;</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Server layout</th>
<th>Recommended MAXDOP</th>
</tr>
</thead>
<tbody>
<tr>
<td>One NUMA node, 8 or fewer logical processors</td>
<td>Up to the number of logical processors</td>
</tr>
<tr>
<td>One NUMA node, more than 8 logical processors</td>
<td>8</td>
</tr>
<tr>
<td>Several NUMA nodes, 16 or fewer logical processors per node</td>
<td>Up to the number of logical processors in one node</td>
</tr>
<tr>
<td>Several NUMA nodes, more than 16 logical processors per node</td>
<td>Half the logical processors in one node, with a maximum of 16</td>
</tr>
</tbody>
</table>
</figure>
<p>&nbsp;</p>
<p>For example, a server with two NUMA nodes of 24 logical processors each would start at 12. A server with two nodes of 40 logical processors each would start at 16, because half of 40 is 20 and the cap is 16.</p>
<p>&nbsp;</p>
<ul>
<li><strong>Mixed workloads:</strong> Use <code>ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP</code> so each database gets its own limit.</li>
<li><strong>One workload that needs a different limit:</strong> Create a Resource Governor workload group with its own <code>MAX_DOP</code>.</li>
<li><strong>MAXDOP 1 server-wide:</strong> Avoid it. Parallel index builds and reporting queries lose parallelism, and maintenance windows get longer.</li>
</ul>
<h3></h3>
<h3>Cost Threshold for Parallelism and Optimize for Ad Hoc Workloads</h3>
<p>&nbsp;</p>
<p>The default cost threshold for parallelism is 5, which is too low for modern hardware. Small queries go parallel and waste threads on coordination.</p>
<p>&nbsp;</p>
<p>Raise it based on observed plan costs. A value in the 25 to 50 range is a reasonable test value, not a rule. After the change, compare CXPACKET and CXCONSUMER waits before and after, and check that your important queries did not slow down.</p>
<p>&nbsp;</p>
<p>Enable <code>optimize for ad hoc workloads</code> when the plan cache fills with single-use plans, e.g. an ORM that sends unparameterized text. On first run SQL Server stores only a small plan stub, and the full plan is cached on the second run. The trade-off is one extra compile for queries that do repeat.</p>
<p>&nbsp;</p>
<h3>tempdb Configuration</h3>
<p>&nbsp;</p>
<p>Use several data files of equal size, pre-sized, with a fixed growth increment, on fast storage. Autogrow should be an emergency only, not the sizing method.</p>
<p>Many sessions create temporary objects at once, and they all compete for the allocation pages (PFS, GAM, SGAM) in a single file. That shows up as PAGELATCH waits. More files spread the allocations. SQL Server fills files in proportion to free space, so unequal sizes send most of the work to one file.</p>
<p>Use this rule for the file count, which follows Microsoft guidance:</p>
<ol>
<li>If the server has 8 or fewer logical processors, create one data file per logical processor (e.g. 4 logical processors, 4 files).</li>
<li>If the server has more than 8 logical processors, start with 8 data files.</li>
<li>If PAGELATCH contention on tempdb allocation pages continues with 8 files, add files in multiples of 4, and stop when the contention goes away.</li>
</ol>
<p>More files than needed do not help and add some overhead, so do not match the core count on a large server.</p>
<h3>SQL Server 2025 Additions</h3>
<ul>
<li><strong>tempdb space governance:</strong> Resource Governor can limit tempdb space per workload group. A workload that exceeds its limit is stopped with an error.</li>
<li><strong>Accelerated Database Recovery in tempdb:</strong> Now supported.</li>
<li><strong>Resource Governor in Standard edition:</strong> It has the same functionality as Enterprise. Standard 2025 is limited to the lesser of 4 sockets or 32 cores, and 256 GB of buffer pool per instance.</li>
<li></li>
</ul>
<h3>Storage and OS Settings</h3>
<p>&nbsp;</p>
<p>Separate data, log and tempdb volumes where your storage design allows it. Put the log on fast storage or RAID 10, because log writes are sequential and every commit waits for them (WRITELOG).</p>
<p>Enable instant file initialization (IFI) by granting the &#8220;Perform volume maintenance tasks&#8221; privilege to the SQL Server service account. With IFI, data file growth and restores skip zeroing the new space, so they finish much faster.</p>
<p>Log files are different, and the behavior depends on the version. In SQL Server 2019 and earlier, log files are always zero-initialized. In SQL Server 2022 and later, transaction log growth events of up to 64 MB can also use IFI. Larger log growths, and the initial creation of a log file, are still zeroed.</p>
<p>For the versions this guide targets, set the log autogrowth increment to 64 MB or less so that emergency growths are fast. Still pre-size the log to its working size, because a log that grows often creates many virtual log files and slows recovery.</p>
<p>&nbsp;</p>
<h3>Sample Configuration Script</h3>
<p>The example below assumes one instance on a 32 GB server, so 75% is 24576 MB. It also assumes a single NUMA node with 16 logical processors. By the Microsoft rule, a single node with more than 8 logical processors gets MAXDOP 8, and the same layout gives 8 tempdb data files. Adjust all values for your own hardware.</p>
<p>&nbsp;</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;

EXEC sp_configure 'max server memory (MB)', 24576;
EXEC sp_configure 'cost threshold for parallelism', 50;
EXEC sp_configure 'optimize for ad hoc workloads', 1;
RECONFIGURE;

-- Run in the user database that needs its own limit.
-- 8 = single NUMA node with 16 logical processors (more than 8, so cap at 8).
ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 8;

-- Verify the instance settings
SELECT name, value, value_in_use
FROM sys.configurations
WHERE name IN ('max server memory (MB)',
               'min server memory (MB)',
               'cost threshold for parallelism',
               'optimize for ad hoc workloads');

-- Check the NUMA layout and logical processor count used for MAXDOP and tempdb sizing
SELECT cpu_count FROM sys.dm_os_sys_info;

SELECT node_id, online_scheduler_count
FROM sys.dm_os_nodes
WHERE node_state_desc = N'ONLINE';

-- Check tempdb file layout (expect 8 data files on this 16-processor server)
SELECT name, physical_name,
       size * 8 / 1024 AS size_mb,
       growth, is_percent_growth
FROM sys.master_files
WHERE database_id = 2;</code></pre>
<p>Run the NUMA queries first and change MAXDOP if your layout differs. The last query should show equal <code>size_mb</code> values, a fixed growth in MB and <code>is_percent_growth</code> set to 0 for every data file.</p>
<h2 id="data-modeling-and-indexing-best-practices">Data Modeling and Indexing Best Practices</h2>
<p>Good SQL Server performance starts with the table design, not the server settings. Model the data cleanly, choose small data types, and add only the indexes your queries use. These rules apply to both SQL Server 2022 and 2025.</p>
<h3>Model to Third Normal Form First</h3>
<p>Start with third normal form (3NF), where each non-key column depends only on the key. Denormalize only when a measured query problem justifies it.</p>
<p>Normalized tables avoid update anomalies, because a customer address lives in one row and not in every order. Rows stay smaller, so more rows fit on a page. Updates touch fewer rows, which means fewer locks.</p>
<h3>Use the Narrowest Correct Data Type and Add Constraints</h3>
<p>Pick the smallest type that safely holds the data. The clustered index key is carried into every nonclustered index as the row locator, so a wide clustered key makes every nonclustered index larger. Wide columns in any index key also add to index size, buffer pool memory and I/O.</p>
<ul>
<li><strong>int vs bigint:</strong> Use <code>int</code> unless you expect more than about 2.1 billion rows.</li>
<li><strong>date vs datetime2:</strong> Use <code>date</code> when no time is needed, and <code>datetime2(0)</code> for second precision.</li>
<li><strong>Strings:</strong> Avoid <code>nvarchar(max)</code> for short values. Use a sized type such as <code>nvarchar(100)</code>.</li>
</ul>
<p>Declare primary key, foreign key, unique and check constraints. The optimizer uses them to build better plans, and they reject bad data at the source, before it reaches the application.</p>
<h3>Choose a Narrow, Ever-Increasing Clustered Key</h3>
<p>Give every table a clustered index on a narrow, unique, ever-increasing key, such as an <code>int IDENTITY</code> column. New rows are appended at the end of the table, and the small key is copied into each nonclustered index.</p>
<p>A random GUID from <code>NEWID()</code> inserts rows at random positions, which causes page splits and fragmentation. A GUID is also 16 bytes, which makes it a wide clustered key. If you need GUIDs, use <code>NEWSEQUENTIALID()</code> as a column default, or keep an identity column as the clustered key and the GUID as a separate unique column.</p>
<h3>Design Nonclustered Indexes for Your Queries</h3>
<p>Index the columns used in <code>WHERE</code>, <code>JOIN</code> and <code>ORDER BY</code>. Put equality columns first and the range column after them. Use <code>INCLUDE</code> for columns that are only selected, so the index covers the query without key lookups.</p>
<p>Example: a retail site lists one customer&#8217;s recent orders, filtering on <code>CustomerId</code> (equality) and <code>OrderDate</code> (range).</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">CREATE TABLE dbo.Orders
(
    OrderId     int IDENTITY(1,1) NOT NULL,
    CustomerId  int NOT NULL,
    OrderDate   datetime2(0) NOT NULL,
    Status      tinyint NOT NULL,
    TotalAmount decimal(10,2) NOT NULL,
    CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderId),
    CONSTRAINT FK_Orders_Customers FOREIGN KEY (CustomerId)
        REFERENCES dbo.Customers (CustomerId),
    CONSTRAINT CK_Orders_TotalAmount CHECK (TotalAmount &gt;= 0)
);

CREATE NONCLUSTERED INDEX IX_Orders_Customer_Date
ON dbo.Orders (CustomerId, OrderDate)
INCLUDE (Status, TotalAmount);</code></pre>
<p>This creates a narrow clustered key, a foreign key, a check constraint and a covering index for the customer-orders query.</p>
<h3>Remove Duplicate and Unused Indexes</h3>
<p>Every index slows inserts, updates and deletes and uses storage. Check <code>sys.dm_db_index_usage_stats</code> to find indexes that are written to but never read.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">SELECT OBJECT_SCHEMA_NAME(i.object_id) AS SchemaName,
       OBJECT_NAME(i.object_id)        AS TableName,
       i.name                          AS IndexName,
       ISNULL(s.user_seeks, 0)   AS UserSeeks,
       ISNULL(s.user_scans, 0)   AS UserScans,
       ISNULL(s.user_lookups, 0) AS UserLookups,
       ISNULL(s.user_updates, 0) AS UserUpdates
FROM sys.indexes AS i
LEFT JOIN sys.dm_db_index_usage_stats AS s
       ON s.object_id = i.object_id
      AND s.index_id = i.index_id
      AND s.database_id = DB_ID()
WHERE i.type_desc = 'NONCLUSTERED'
  AND i.is_primary_key = 0
  AND i.is_unique_constraint = 0
  AND OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1
  AND ISNULL(s.user_seeks, 0) + ISNULL(s.user_scans, 0) + ISNULL(s.user_lookups, 0) = 0
ORDER BY UserUpdates DESC;</code></pre>
<p>This lists nonclustered indexes with no reads, sorted by write cost. The usage counters reset when the instance restarts, so observe a full business cycle (e.g. a month-end close) before dropping anything.</p>
<h3>Maintain Statistics First, Fragmentation Second</h3>
<p>Update statistics regularly, because the optimizer chooses plans from them. Stale statistics cause bad row estimates and bad plans.</p>
<p>For fragmentation, act on a threshold and not on a blanket schedule. Measure it with <code>sys.dm_db_index_physical_stats</code>, then pick an action from the table below. These numbers are starting points only, so adjust them after you see how your own workload and storage respond.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Index fragmentation</th>
<th>Starting-point action</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td>Below 5%</td>
<td>Do nothing</td>
<td>The maintenance cost is higher than any benefit.</td>
</tr>
<tr>
<td>5% to 30%</td>
<td><code>ALTER INDEX ... REORGANIZE</code></td>
<td>Reorganize is an online operation that compacts pages in place, using little extra space.</td>
</tr>
<tr>
<td>Above 30%</td>
<td><code>ALTER INDEX ... REBUILD</code></td>
<td>Rebuild creates the index again, which removes fragmentation and updates the index statistics with a full scan.</td>
</tr>
</tbody>
</table>
</figure>
<p>Ignore small indexes, for example those under about 1,000 pages, because their fragmentation figures are not meaningful and rarely affect performance. Fragmentation matters less on SSD storage, but statistics matter just as much.</p>
<h3>SQL Server 2025 Vector Data</h3>
<p>The <code>vector</code> data type is generally available in SQL Server 2025. DiskANN vector indexes are still preview only, need the <code>PREVIEW_FEATURES</code> database scoped configuration, and are not recommended for production yet.</p>
<h2 id="query-optimization-and-sql-server-performance-tuning-best-pr">Query Optimization and SQL Server Performance Tuning Best Practices</h2>
<p>Most SQL Server performance tuning comes down to a few habits: write predicates the optimizer can seek on, return only the data you need, reuse plans, and watch for plan regressions with Query Store.</p>
<h3>Write SARGable Predicates</h3>
<p>A SARGable predicate (Search ARGument able) lets the engine use an index seek. Wrapping an indexed column in a function forces a scan, because the function must run on every row.</p>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">-- Bad: function on the column, index on OrderDate cannot be used for a seek
SELECT OrderId, TotalAmount
FROM dbo.Orders
WHERE YEAR(OrderDate) = 2026;

-- Good: range on the bare column, index seek is possible
SELECT OrderId, TotalAmount
FROM dbo.Orders
WHERE OrderDate &gt;= '20260101'
  AND OrderDate &lt;  '20270101';</code></pre>
<p>The rewritten query returns the same rows and reads only the matching index range.</p>
<h3>Return Less Data, Use Fewer Round Trips</h3>
<ul>
<li><strong>Select only needed columns:</strong> Avoid <code>SELECT *</code>. It reads and sends extra data, and it stops narrow covering indexes from being used.</li>
<li><strong>SET NOCOUNT ON:</strong> Add it at the top of procedures to stop the &#8220;rows affected&#8221; message after every statement.</li>
<li><strong>Set-based code:</strong> Use one <code>UPDATE ... JOIN</code> instead of a cursor. A cursor processes one row at a time, which costs more CPU and more statements.</li>
</ul>
<p>Example: a retail nightly job that updates stock for 200,000 products should run one set-based statement, not a loop that fires 200,000 updates.</p>
<h3>Handle Parameter Sniffing</h3>
<p>Parameterized queries and stored procedures let SQL Server reuse plans. Parameter sniffing means the plan is built for the first parameter value it sees, which can be a poor fit for later values.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Option</th>
<th>Use when</th>
<th>Trade-off</th>
</tr>
</thead>
<tbody>
<tr>
<td>Parameter-sensitive plan (PSP) optimization (SQL Server 2022 and later, compatibility level 160 or higher)</td>
<td>A parameterized query runs against skewed data, so one value returns a few rows and another returns millions</td>
<td>Applies only to some predicate shapes, and the engine decides when to use it. It is not a fix for every sniffing problem, so check the plans in Query Store</td>
</tr>
<tr>
<td><code>OPTION (RECOMPILE)</code></td>
<td>Rare, expensive queries with very uneven data</td>
<td>Compile cost on every run</td>
</tr>
<tr>
<td><code>OPTIMIZE FOR</code></td>
<td>One typical value is known</td>
<td>Other values may run slowly</td>
</tr>
<tr>
<td>Query Store plan forcing</td>
<td>A known good plan exists</td>
<td>Forced plan can go stale as data changes</td>
</tr>
</tbody>
</table>
</figure>
<p>On 2022 and later, PSP optimization is the first thing to try when you are on compatibility level 160. It lets one parameterized statement keep more than one plan for different parameter ranges. If it does not cover your query, move down the table.</p>
<h3>Use Query Store</h3>
<p>Query Store records queries, plans and runtime statistics inside the database. From SQL Server 2022, it is ON by default for new databases. The &#8220;enable it&#8221; step therefore mostly applies to databases that were upgraded or restored from an older instance, because they keep their earlier setting.</p>
<p>Check which databases still have it off, and turn it on for the ones you care about. Use <code>QUERY_CAPTURE_MODE = AUTO</code> so trivial queries do not fill the store.</p>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">SELECT name, is_query_store_on
FROM sys.databases
WHERE database_id &gt; 4;</code></pre>
<p>This lists user databases and shows whether Query Store is on for each one.</p>
<p>On readable secondary replicas, the defaults differ by version. In SQL Server 2025, Query Store on readable secondaries is on by default. In SQL Server 2022 it was off and needed trace flag 12606.</p>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">ALTER DATABASE SalesDb SET QUERY_STORE = ON
(
    OPERATION_MODE = READ_WRITE,
    QUERY_CAPTURE_MODE = AUTO,
    MAX_STORAGE_SIZE_MB = 1024,
    INTERVAL_LENGTH_MINUTES = 60,
    SIZE_BASED_CLEANUP_MODE = AUTO
);</code></pre>
<p>The sizes above are example values. Adjust them to your workload.</p>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">SELECT TOP (10)
    q.query_id,
    LEFT(qt.query_sql_text, 200) AS query_text,
    SUM(rs.count_executions * rs.avg_duration) / 1000.0 AS total_duration_ms
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
GROUP BY q.query_id, qt.query_sql_text
ORDER BY total_duration_ms DESC;</code></pre>
<p>This lists the ten queries that consumed the most total time. Durations are stored in microseconds, so the query divides by 1000.</p>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">EXEC sys.sp_query_store_force_plan @query_id = 42, @plan_id = 17;

-- Remove the forced plan when it is no longer needed
EXEC sys.sp_query_store_unforce_plan @query_id = 42, @plan_id = 17;</code></pre>
<p>Replace the IDs with values from the previous query and the Query Store reports.</p>
<h3>Test Feedback Features and Compatibility Levels</h3>
<p>The optimizer can adjust some settings for a query after it has run, using what it saw on earlier executions. Two feedback features matter most, and each needs a minimum compatibility level.</p>
<ul>
<li><strong>Memory grant feedback:</strong> Resizes the memory granted to a query when earlier runs got too much or too little, which reduces tempdb spills and wasted memory. Batch mode needs compatibility level 140 or higher. Row mode needs 150 or higher. In SQL Server 2022, the percentile and persisted versions of this feedback need Query Store to be on and in read-write mode.</li>
<li><strong>DOP feedback:</strong> Lowers the degree of parallelism (DOP, the number of threads a query uses) for parallel queries that do not benefit from it. It is available in SQL Server 2022 and later, needs compatibility level 160 or higher, and needs Query Store in read-write mode. In 2022, check that the <code>DOP_FEEDBACK</code> database scoped configuration is on for the database.</li>
</ul>
<p>Raise the compatibility level on a copy of the database first, compare plans, and force the old plan where a regression appears. Check the Microsoft documentation for the exact compatibility level that matches your SQL Server version before you change it.</p>
<h3>Read Execution Plans</h3>
<ol>
<li><strong>Estimated vs actual rows:</strong> Large gaps usually mean stale statistics or a poor cardinality estimate.</li>
<li><strong>Spills to tempdb:</strong> A sort or hash warning means the memory grant was too small.</li>
<li><strong>Key lookups:</strong> Many lookups suggest the index needs an <code>INCLUDE</code> column.</li>
<li><strong>Implicit conversions:</strong> A parameter type that differs from the column type (e.g. <code>nvarchar</code> against <code>varchar</code>) can block an index seek.</li>
</ol>
<h2 id="transactions-isolation-levels-and-optimized-locking">Transactions, Isolation Levels and Optimized Locking</h2>
<p>Keep transactions short, touch tables in the same order everywhere, and use row versioning (Read Committed Snapshot Isolation, or RCSI) for read-heavy OLTP so readers don&#8217;t block writers. These habits are part of SQL Server performance tuning best practices because blocking often looks like a slow query.</p>
<h3>Isolation Levels Compared</h3>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Level</th>
<th>What it prevents</th>
<th>Version store cost</th>
<th>Use when</th>
</tr>
</thead>
<tbody>
<tr>
<td>READ COMMITTED (locking)</td>
<td>Dirty reads</td>
<td>None</td>
<td>Default; low-concurrency workloads</td>
</tr>
<tr>
<td>READ COMMITTED SNAPSHOT (RCSI)</td>
<td>Dirty reads; readers don&#8217;t wait for writers</td>
<td>Row versions in tempdb, or in the PVS with ADR</td>
<td>Read-heavy OLTP</td>
</tr>
<tr>
<td>SNAPSHOT</td>
<td>Dirty reads, non-repeatable reads, phantoms within a transaction</td>
<td>Same store; versions live as long as the oldest open transaction</td>
<td>Consistent multi-statement reads</td>
</tr>
<tr>
<td>SERIALIZABLE</td>
<td>All of the above, using range locks</td>
<td>None, but heavy locking</td>
<td>Strict invariants, used rarely</td>
</tr>
</tbody>
</table>
</figure>
<h3>RCSI Trade-offs</h3>
<p>RCSI makes a SELECT read the last committed version of a row instead of waiting for a lock. The cost is extra version storage, normally in tempdb. With Accelerated Database Recovery (ADR), the version store lives in the user database (the persistent version store, PVS).</p>
<p>Behaviour also changes. Code that relied on a blocking read to wait for a writer, such as a &#8220;read then update&#8221; pattern, now sees the old value. Test this in a copy of the database first.</p>
<h3>Optimized Locking in SQL Server 2025</h3>
<p>SQL Server 2025 adds optimized locking, which uses transaction ID (TID) locking and lock after qualification (LAQ). A TID lock replaces many row locks, and LAQ takes locks only on the rows that match the predicate. This reduces blocking and lock memory use, avoids lock escalation, and needs no application code change. It is a good reason to test 2025 with write-heavy workloads.</p>
<p>Optimized locking is not on by default for every database. It requires Accelerated Database Recovery (ADR) to be enabled on the database, so turn on ADR first and check the result in <code>sys.databases</code>. Confirm the exact database-level setting for optimized locking in the Microsoft documentation for your build.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">-- Prerequisite for optimized locking: ADR on the database.
-- Test first and run in a quiet window.
ALTER DATABASE BankDb SET ACCELERATED_DATABASE_RECOVERY = ON;
GO

SELECT name, is_accelerated_database_recovery_on
FROM sys.databases
WHERE name = N'BankDb';</code></pre>
<p>This enables ADR and confirms it with a check of <code>is_accelerated_database_recovery_on</code>.</p>
<p>RCSI and optimized locking address different parts of the blocking problem, so measure them with and without RCSI on your own workload instead of assuming the combination is better. The animated diagram below compares classic locking with optimized locking plus RCSI for two sessions on the same table.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/sql-server-best-practices-guidelines-diagram-5.gif" alt="Classic locking escalates to a table lock and blocks a SELECT; optimized locking with RCSI uses one TID lock and does not block." width="1200" height="675" /><figcaption>Classic locking escalates and blocks readers; optimized locking with RCSI uses a TID lock and readers see row versions.</figcaption></figure>
<h3>Deadlocks and Retries</h3>
<p>When SQL Server picks your session as a deadlock victim, it returns error 1205. Retry in application code with a bounded count (e.g. three attempts) and a short backoff. To see the cause, read the deadlock graph from the <code>system_health</code> Extended Events session.</p>
<h3>Finance Transfer Example</h3>
<p>Move money in one short transaction. Validate the inputs first, then update the two accounts with two separate statements, lowest ID first. Because every transfer takes the locks in the same order, two opposite transfers (A to B and B to A) can&#8217;t deadlock on these two rows.</p>
<p>The balance check sits inside the debit <code>UPDATE</code>&#8216;s <code>WHERE</code> clause. The row is locked when the check runs, so it sees the current balance. A separate <code>SELECT</code> before the update could read an older row version under RCSI.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">-- Enable RCSI (rolls back open transactions, so run in a quiet window)
ALTER DATABASE BankDb SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
GO

CREATE OR ALTER PROCEDURE dbo.TransferFunds
    @FromId int, @ToId int, @Amount decimal(19,4)
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    -- Validate inputs before opening a transaction
    IF @Amount IS NULL OR @Amount &lt;= 0
        THROW 50001, 'Amount must be greater than zero.', 1;
    IF @FromId IS NULL OR @ToId IS NULL OR @FromId = @ToId
        THROW 50002, 'Source and target accounts must be different.', 1;

    -- Work out the lock order: lowest AccountId first
    DECLARE @LowId  int = CASE WHEN @FromId &lt; @ToId THEN @FromId ELSE @ToId END;
    DECLARE @HighId int = CASE WHEN @FromId &lt; @ToId THEN @ToId ELSE @FromId END;

    -- Signed change for each account: negative for the debit, positive for the credit
    DECLARE @LowDelta  decimal(19,4) = CASE WHEN @LowId  = @FromId THEN -@Amount ELSE @Amount END;
    DECLARE @HighDelta decimal(19,4) = CASE WHEN @HighId = @FromId THEN -@Amount ELSE @Amount END;

    BEGIN TRY
        BEGIN TRANSACTION;

        UPDATE dbo.Accounts
           SET Balance = Balance + @LowDelta
         WHERE AccountId = @LowId
           AND (@LowDelta &gt; 0 OR Balance &gt;= @Amount);
        IF @@ROWCOUNT = 0
            THROW 50003, 'Account not found or insufficient funds.', 1;

        UPDATE dbo.Accounts
           SET Balance = Balance + @HighDelta
         WHERE AccountId = @HighId
           AND (@HighDelta &gt; 0 OR Balance &gt;= @Amount);
        IF @@ROWCOUNT = 0
            THROW 50003, 'Account not found or insufficient funds.', 1;

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT &gt; 0 ROLLBACK TRANSACTION;
        THROW;
    END CATCH
END;</code></pre>
<p>The procedure rejects zero, negative and NULL amounts, and it rejects a transfer from an account to itself. If either update finds no row to change, the transaction rolls back, so a failed debit never leaves a half-finished credit behind. <code>XACT_ABORT ON</code> also rolls back the whole transaction on any runtime error.</p>
<p>The fixed order only protects code that follows it. Any other procedure that updates the same accounts in a different order, or a trigger that touches other rows, can still deadlock with this one. Keep the retry logic for error 1205 in place.</p>
<h2 id="connection-management-and-microsoft-data-sqlclient-best-prac">Connection Management and Microsoft.Data.SqlClient Best Practices</h2>
<p>Use Microsoft.Data.SqlClient (v7.0.1 is shown in the current API docs) and keep the defaults <code>Encrypt=Mandatory</code> and <code>TrustServerCertificate=false</code>. When an app starts failing with certificate errors after a driver upgrade, the driver is not broken. The client simply does not trust the server certificate. Fix the certificate chain or the host name instead of switching on <code>TrustServerCertificate</code>. This is one of the most useful SQL Server best practices for developers.</p>
<p>The animated diagram below shows how the client decides whether a connection succeeds and which fix to choose.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/sql-server-best-practices-guidelines-diagram-6.gif" alt="Decision flow from opening a connection through Encrypt settings and certificate checks to a connected result or a certificate error with two fixes." width="1200" height="675" /><figcaption>Certificate errors come from failed server identity checks; fix the certificate and host name rather than enabling TrustServerCertificate.</figcaption></figure>
<h3>Encrypt Options Compared</h3>
<p><code>Encrypt=Strict</code> uses TDS 8.0 and cannot be bypassed with <code>TrustServerCertificate</code>. Use it when your server supports TDS 8.0. Avoid <code>TrustServerCertificate=true</code> in production, because it skips the server identity check.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Encrypt value</th>
<th>Behaviour</th>
<th>Certificate validation</th>
<th>Use when</th>
</tr>
</thead>
<tbody>
<tr>
<td>Optional</td>
<td>Encrypts only if the server requires it</td>
<td>Only when encryption is used</td>
<td>Legacy apps during migration, not new work</td>
</tr>
<tr>
<td>Mandatory (default)</td>
<td>Always encrypts</td>
<td>Yes, unless <code>TrustServerCertificate=true</code></td>
<td>Most production apps</td>
</tr>
<tr>
<td>Strict</td>
<td>TDS 8.0, TLS first</td>
<td>Always, cannot be bypassed</td>
<td>Server supports TDS 8.0</td>
</tr>
</tbody>
</table>
</figure>
<h3>Connection Pooling Rules</h3>
<ul>
<li><strong>Open late, close early:</strong> Use <code>using</code> or <code>await using</code> so the connection returns to the pool even when an exception occurs.</li>
<li><strong>One connection per thread:</strong> A <code>SqlConnection</code> is not thread-safe, so never share it.</li>
<li><strong>Keep connection strings identical:</strong> Each distinct string creates its own pool. Per-request values (e.g. a user name in the string) fragment the pool.</li>
<li><strong>Watch Max Pool Size:</strong> The default is 100. Pool exhaustion shows up as &#8220;timeout period elapsed prior to obtaining a connection from the pool&#8221;. The usual cause is a leaked connection, so look for missing disposal before raising the limit.</li>
</ul>
<h3>Availability Groups and Read-Only Routing</h3>
<p>Connect to the availability group listener name, never a node name. Add <code>MultiSubnetFailover=True</code> when the listener spans subnets, so the client tries all IP addresses in parallel. Add <code>ApplicationIntent=ReadOnly</code> for reporting workloads that should use read-only routing.</p>
<h3>C# Example: Strict Encryption and a Typed Parameter</h3>
<p>Example: a retail app looks up orders for one customer. The parameter has an explicit type and size, which avoids implicit conversions and plan cache bloat.</p>
<p class="code-label"><strong>C# (.NET)</strong></p>
<pre><code class="language-csharp">using System.Data;
using Microsoft.Data.SqlClient;

var builder = new SqlConnectionStringBuilder
{
    DataSource = "orders-listener.contoso.com",
    InitialCatalog = "Sales",
    IntegratedSecurity = true,
    Encrypt = SqlConnectionEncryptOption.Strict,
    MultiSubnetFailover = true,
    ApplicationIntent = ApplicationIntent.ReadWrite,
    MaxPoolSize = 100
};

await using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();

await using var command = new SqlCommand(
    "SELECT OrderId, OrderDate FROM dbo.Orders WHERE CustomerRef = @CustomerRef;",
    connection);
command.Parameters.Add("@CustomerRef", SqlDbType.VarChar, 20).Value = "C-10045";

await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
    Console.WriteLine($"{reader.GetInt32(0)} {reader.GetDateTime(1):d}");
}</code></pre>
<p>This opens a strict, encrypted connection and returns it to the pool when the block ends.</p>
<p>For transient errors, the driver offers configurable retry logic (<code>SqlRetryLogicOptions</code> with <code>SqlConfigurableRetryFactory</code>). Assign a provider to the connection or command, and keep the retry count and delay bounded.</p>
<h3>Python Equivalent with pyodbc</h3>
<p>Use the ODBC Driver for SQL Server, set <code>Encrypt=yes</code> and <code>TrustServerCertificate=no</code>, and pass values through <code>?</code> placeholders. Match the driver name to the one installed on your machine.</p>
<p class="code-label"><strong>Python</strong></p>
<pre><code class="language-python">import os
import pyodbc

conn_str = (
    "DRIVER={ODBC Driver 18 for SQL Server};"
    "SERVER=orders-listener.contoso.com;"
    "DATABASE=Sales;"
    f"UID={os.environ['SQL_USER']};PWD={os.environ['SQL_PASSWORD']};"
    "Encrypt=yes;TrustServerCertificate=no;"
    "MultiSubnetFailover=Yes;"
)

with pyodbc.connect(conn_str) as conn:
    cursor = conn.cursor()
    cursor.execute(
        "SELECT OrderId, OrderDate FROM dbo.Orders WHERE CustomerRef = ?",
        "C-10045",
    )
    for row in cursor.fetchall():
        print(row.OrderId, row.OrderDate)</code></pre>
<p>This keeps the password out of the source code and sends the value as a parameter, not as part of the SQL text.</p>
<h2 id="sql-server-security-best-practices-least-privilege-injection">SQL Server Security Best Practices: Least Privilege, Injection and Encryption</h2>
<p>Use Windows or Microsoft Entra authentication, give each application its own login with only the permissions it needs, and never connect an application as <code>sa</code> or as a member of <code>db_owner</code>. These SQL Server security best practices limit the damage if one credential or one query is compromised.</p>
<h3>Apply the Least Privilege Pattern</h3>
<p>Create a database role for the application, grant it <code>EXECUTE</code> on a schema, and give it no direct table access. Stored procedures and tables owned by the same principal use ownership chaining, so the procedure can read the tables even though the caller cannot.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Permission model</th>
<th>Risk</th>
<th>Use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>db_owner</code></td>
<td>Full control of the database, including dropping objects and changing permissions</td>
<td>DBAs and deployment accounts only</td>
</tr>
<tr>
<td><code>db_datareader</code> / <code>db_datawriter</code></td>
<td>Read or write every table, including future ones. An injection flaw exposes all data</td>
<td>Simple tools and trusted internal jobs</td>
</tr>
<tr>
<td>Custom role with <code>EXECUTE</code> on a schema</td>
<td>Limited to the procedures in that schema</td>
<td>Default for application connections</td>
</tr>
</tbody>
</table>
</figure>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">CREATE LOGIN [CORP\svc-orders-app] FROM WINDOWS;
GO
USE OrdersDb;
GO
CREATE SCHEMA app AUTHORIZATION dbo;
GO
CREATE USER [CORP\svc-orders-app] FOR LOGIN [CORP\svc-orders-app];
CREATE ROLE app_orders_role;
GRANT EXECUTE ON SCHEMA::app TO app_orders_role;
ALTER ROLE app_orders_role ADD MEMBER [CORP\svc-orders-app];</code></pre>
<p>This creates the login, the user, and a role that can only run procedures in the <code>app</code> schema.</p>
<h3>Prevent SQL Injection in Dynamic SQL</h3>
<p>Build dynamic SQL with parameters through <code>sp_executesql</code> and never concatenate user input into the query text. Microsoft guidance also says to review every piece of code that calls <code>EXEC</code>, <code>EXECUTE</code> or <code>sp_executesql</code>. Dynamic SQL also breaks ownership chaining, so the caller needs permissions on the underlying tables.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">DECLARE @sql nvarchar(max) =
    N'SELECT OrderId, OrderDate, Total
      FROM dbo.Orders
      WHERE CustomerId = @CustomerId AND OrderDate &gt;= @From;';

EXEC sys.sp_executesql
     @sql,
     N'@CustomerId int, @From date',
     @CustomerId = 42,
     @From = '2026-01-01';

-- Object names cannot be parameters, so quote them
DECLARE @table sysname = N'Orders';
SET @sql = N'SELECT COUNT(*) FROM dbo.' + QUOTENAME(@table) + N';';
EXEC sys.sp_executesql @sql;</code></pre>
<p>Typed parameters also give you plan reuse. <code>QUOTENAME</code> is for object names only, and you should still check them against an allow-list.</p>
<h3>Encrypt Data in Transit, at Rest and in Columns</h3>
<ul>
<li><strong>In transit:</strong> Use TLS with a valid server certificate. See the connection management section for <code>Encrypt</code> settings.</li>
<li><strong>At rest:</strong> Enable Transparent Data Encryption (TDE) to protect data files and backups from a stolen disk or file copy. TDE does not protect against a user who has permission to query the data.</li>
<li><strong>Sensitive columns:</strong> Use Always Encrypted when even DBAs should not read the values. The keys stay with the client.</li>
<li><strong>Backups:</strong> Encrypt them, and store the certificate or key separately. Without it, the backup cannot be restored.</li>
</ul>
<h3>Audit Activity and Reduce the Attack Surface</h3>
<p>Use SQL Server Audit to write to a file or the Windows event log. Capture logins, permission changes and access to sensitive tables. Disable or rename <code>sa</code> if you don&#8217;t need it, and keep <code>xp_cmdshell</code> and other unused features turned off. This is a core item on any SQL Server DBA checklist.</p>
<h3>Example: Patient Data in a Healthcare System</h3>
<p>Give the reporting team a read-only role with column-level permissions, so identifying columns stay out of reach.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">CREATE ROLE reporting_role;
GRANT SELECT ON dbo.Patient (PatientId, AdmissionDate, WardCode) TO reporting_role;
DENY  SELECT ON dbo.Patient (FullName, NationalId) TO reporting_role;</code></pre>
<p>Dynamic data masking can hide values in query results, but it is a convenience and not a security boundary. A user who can filter or infer values can still work around it, so rely on permissions and encryption.</p>
<h2 id="sql-server-backup-best-practices-high-availability-and-resto">SQL Server Backup Best Practices, High Availability and Restore Testing</h2>
<p>A backup is only proven when you have restored it. Match the recovery model to your recovery point objective (RPO, the amount of data loss you can accept), take full, differential and log backups, keep copies off the server, and test restores on a schedule. These are the core SQL Server backup best practices.</p>
<h3>Choose a Backup Strategy That Matches Your RPO and RTO</h3>
<p>The recovery model decides which backups are possible. RTO means recovery time objective, the time you can afford to be down.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Strategy</th>
<th>RPO</th>
<th>RTO</th>
<th>Log growth risk</th>
</tr>
</thead>
<tbody>
<tr>
<td>Full only (SIMPLE recovery)</td>
<td>Back to the last full backup</td>
<td>Short, one restore</td>
<td>Low, the log is reused automatically</td>
</tr>
<tr>
<td>Full + differential (SIMPLE)</td>
<td>Back to the last differential</td>
<td>Short, two restores</td>
<td>Low</td>
</tr>
<tr>
<td>Full + differential + log (FULL recovery)</td>
<td>Minutes, set by log backup interval</td>
<td>Longer, a full chain plus point-in-time options</td>
<td>High if log backups stop</td>
</tr>
</tbody>
</table>
</figure>
<p>In FULL recovery, SQL Server keeps log records until a log backup has captured them. With no log backups, the log cannot be reused, so it grows until the disk is full. Switch to SIMPLE if you do not need point-in-time recovery.</p>
<p>Example: a retail orders database takes a full backup nightly, a differential every four hours and a log backup every 10 minutes. That gives an example RPO of about 10 minutes. It is an illustration, not a recommendation, so set your own interval from business needs.</p>
<h3>Use CHECKSUM, Compression and Encryption on Every Backup</h3>
<p>CHECKSUM detects page corruption while the backup runs. Compression reduces size and I/O. Encryption protects the file once it leaves the server.</p>
<p><code>RESTORE VERIFYONLY</code> is the minimum check. It confirms the backup is readable, but it does not prove the data is consistent. Only a real restore followed by <code>DBCC CHECKDB</code> on the restored copy does that.</p>
<h3>ZSTD Compression and Secondary Backups in SQL Server 2025</h3>
<p>ZSTD backup compression is GA in SQL Server 2025 but is not the default. Enable it per backup with <code>COMPRESSION (ALGORITHM = ZSTD, LEVEL = LOW | MEDIUM | HIGH)</code>, or instance-wide by setting <code>backup compression algorithm</code> to 3 with <code>sp_configure</code>. Test CPU cost and backup time first, because HIGH can take much longer.</p>
<p>Full, differential and log backups can now run on availability group secondary replicas (GA), which takes load off the primary. Backups to immutable storage are also GA, which helps against ransomware.</p>
<p>The animated diagram below shows the full path from backup to a verified restore.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/sql-server-best-practices-guidelines-diagram-8.gif" alt="Pipeline: primary or AG secondary emits backups through a compress, encrypt, CHECKSUM gate to local and offsite copies, then restore test and CHECKDB." width="1200" height="675" /><figcaption>A backup is proven only when a scheduled restore plus DBCC CHECKDB succeeds, and the restore time gives your measured RTO.</figcaption></figure>
<h3>High Availability Is Not a Backup</h3>
<p>An availability group replicates a bad <code>DELETE</code> or corruption to the secondary just as quickly. Use HA for uptime and backups for recovery.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Option</th>
<th>Scope</th>
<th>Failover</th>
<th>Readable secondary</th>
<th>Typical use</th>
</tr>
</thead>
<tbody>
<tr>
<td>Always On availability group</td>
<td>Database(s)</td>
<td>Automatic or manual</td>
<td>Yes</td>
<td>HA, read offload, backups on secondary</td>
</tr>
<tr>
<td>Failover cluster instance</td>
<td>Whole instance</td>
<td>Automatic, shared storage</td>
<td>No</td>
<td>Instance-level HA, jobs and logins included</td>
</tr>
<tr>
<td>Log shipping</td>
<td>Database</td>
<td>Manual</td>
<td>Optional, in standby mode</td>
<td>Simple DR to another site</td>
</tr>
</tbody>
</table>
</figure>
<h3>Protect Copies, Credentials and Keys</h3>
<p>Keep one copy on a local share for fast restores and one offsite or on immutable storage. Document the backup credential, certificate and private key, and store them separately from the backups. If you lose the certificate, encrypted backups cannot be read.</p>
<h3>Backup and Restore Test Script</h3>
<p>The script below backs up, restores to a test name with <code>MOVE</code>, and checks consistency. The test server needs the same certificate. Run <code>RESTORE FILELISTONLY</code> first to get the logical file names.</p>
<p class="code-label"><strong>SQL (T-SQL)</strong></p>
<pre><code class="language-sql">-- Optional: make ZSTD the instance default (SQL Server 2025)
EXEC sp_configure 'backup compression algorithm', 3;
RECONFIGURE;

BACKUP DATABASE SalesDB
TO DISK = N'D:\Backup\SalesDB_full.bak'
WITH CHECKSUM,
     COMPRESSION (ALGORITHM = ZSTD, LEVEL = MEDIUM),
     ENCRYPTION (ALGORITHM = AES_256, SERVER CERTIFICATE = BackupCert),
     STATS = 10;

BACKUP LOG SalesDB
TO DISK = N'D:\Backup\SalesDB_log.trn'
WITH CHECKSUM,
     COMPRESSION (ALGORITHM = ZSTD, LEVEL = LOW),
     ENCRYPTION (ALGORITHM = AES_256, SERVER CERTIFICATE = BackupCert);

-- On the restore test server
RESTORE FILELISTONLY FROM DISK = N'D:\Backup\SalesDB_full.bak';

RESTORE DATABASE SalesDB_RestoreTest
FROM DISK = N'D:\Backup\SalesDB_full.bak'
WITH MOVE N'SalesDB'     TO N'E:\RestoreTest\SalesDB_RestoreTest.mdf',
     MOVE N'SalesDB_log' TO N'F:\RestoreTest\SalesDB_RestoreTest.ldf',
     CHECKSUM, RECOVERY, STATS = 10;

DBCC CHECKDB (SalesDB_RestoreTest) WITH NO_INFOMSGS, ALL_ERRORMSGS;</code></pre>
<p>This restores the full backup under a new name and reports any corruption. Record how long the restore took, because that is your measured RTO.</p>
<p>For a point-in-time restore, restore the full backup (and the latest differential) with <code>NORECOVERY</code>. Then restore each log backup, and add <code>STOPAT = '2026-10-13T09:55:00'</code> to the last one with <code>RECOVERY</code>. This needs FULL recovery and an unbroken log chain.</p>
<h2 id="monitoring-sql-server-wait-stats-extended-events-and-perform">Monitoring SQL Server: Wait Stats, Extended Events and Performance Counters</h2>
<p>Start with wait statistics to see what the instance is waiting on. Then use Query Store and Extended Events to find the queries and events behind those waits. This order is the core of SQL Server performance tuning best practices, because it stops you from guessing.</p>
<h3>Common Wait Types and What They Point To</h3>
<p>A wait type is the reason a request had to pause. Treat it as a pointer, not a diagnosis. Confirm it with a query, a plan or a counter before you change anything.</p>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Wait</th>
<th>Usually points to</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>PAGEIOLATCH_*</code></td>
<td>Slow data storage or memory pressure</td>
</tr>
<tr>
<td><code>LCK_M_*</code></td>
<td>Blocking between sessions</td>
</tr>
<tr>
<td><code>WRITELOG</code></td>
<td>Slow transaction log storage</td>
</tr>
<tr>
<td><code>CXPACKET</code> / <code>CXCONSUMER</code></td>
<td>Parallelism (check MAXDOP and cost threshold)</td>
</tr>
<tr>
<td><code>RESOURCE_SEMAPHORE</code></td>
<td>Queries waiting for memory grants</td>
</tr>
</tbody>
</table>
</figure>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">SELECT TOP (10)
       wait_type,
       waiting_tasks_count,
       wait_time_ms,
       signal_wait_time_ms,
       CAST(100.0 * wait_time_ms / SUM(wait_time_ms) OVER () AS decimal(5,2)) AS pct_of_waits
FROM sys.dm_os_wait_stats
WHERE wait_time_ms &gt; 0
  AND wait_type NOT IN (N'SLEEP_TASK', N'LAZYWRITER_SLEEP', N'XE_TIMER_EVENT',
      N'XE_DISPATCHER_WAIT', N'REQUEST_FOR_DEADLOCK_SEARCH', N'CHECKPOINT_QUEUE',
      N'DIRTY_PAGE_POLL', N'LOGMGR_QUEUE', N'SQLTRACE_INCREMENTAL_FLUSH_SLEEP',
      N'SP_SERVER_DIAGNOSTICS_SLEEP', N'BROKER_TO_FLUSH', N'BROKER_TASK_STOP',
      N'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP', N'QDS_ASYNC_QUEUE', N'WAITFOR')
ORDER BY wait_time_ms DESC;</code></pre>
<p>This returns the top waits since the last restart, minus a partial list of idle waits. Take two snapshots and compare them to see current behaviour.</p>
<h3>Use Extended Events, Not Profiler</h3>
<p>Extended Events is the lightweight tracing system, and SQL Trace and Profiler are deprecated. Keep the built-in <code>system_health</code> session running for deadlock graphs. Add a small session for slow statements.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">CREATE EVENT SESSION [LongRunningStatements] ON SERVER
ADD EVENT sqlserver.sql_statement_completed
(
    ACTION (sqlserver.database_name, sqlserver.client_app_name, sqlserver.sql_text)
    WHERE duration &gt; 1000000   -- microseconds, so 1 second
)
ADD TARGET package0.event_file
(
    SET filename = N'LongRunningStatements.xel', max_file_size = 50, max_rollover_files = 4
)
WITH (MAX_DISPATCH_LATENCY = 30 SECONDS, STARTUP_STATE = ON);

ALTER EVENT SESSION [LongRunningStatements] ON SERVER STATE = START;</code></pre>
<p>The file target rolls over at a fixed size, so the session cannot fill the disk.</p>
<h3>Counters and Alerts Worth Setting Up</h3>
<ul>
<li><strong>Page life expectancy:</strong> Watch the trend against your own baseline. A fixed number such as 300 does not fit every server.</li>
<li><strong>Batch requests per second:</strong> Use it to read load changes next to waits.</li>
<li><strong>Log file percent used:</strong> Alert before the log fills the volume.</li>
<li><strong>Free space:</strong> Check data, log and tempdb volumes separately.</li>
<li><strong>AG synchronization lag:</strong> Alert when secondaries fall behind.</li>
<li><strong>Errors and jobs:</strong> Alert on severity 17 to 25 errors and on job failures.</li>
</ul>
<p>For backups, alert on the age of the last successful backup, not on whether the job ran. A job can finish without producing a usable backup. This also belongs on any SQL Server DBA checklist.</p>
<h2 id="patching-and-upgrading-to-sql-server-2025-what-to-check-firs">Patching and Upgrading to SQL Server 2025: What to Check First</h2>
<p>Patch on a regular cadence, test the upgrade on a copy that has the same linked servers, certificates and jobs, and plan the outage window before you start. Most upgrade surprises come from things outside the database files.</p>
<h3>Version, Support and Current Builds</h3>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Version</th>
<th>Mainstream support</th>
<th>Extended support</th>
<th>Latest build (October 2026)</th>
</tr>
</thead>
<tbody>
<tr>
<td>SQL Server 2019</td>
<td>Ended 28 February 2025</td>
<td>Until 8 January 2030</td>
<td>CU32 15.0.4430.1; CU32+GDR 15.0.4490.9</td>
</tr>
<tr>
<td>SQL Server 2022</td>
<td>Ends 11 January 2028</td>
<td>Check the Microsoft lifecycle page for the end date</td>
<td>CU27 16.0.4295.3</td>
</tr>
<tr>
<td>SQL Server 2025 (GA 18 November 2025, RTM 17.0.1000.7)</td>
<td>Ends 6 January 2031</td>
<td>Check the Microsoft lifecycle page for the end date</td>
<td>CU9 17.0.5005.3 (15 September 2026); GDR 17.0.1135.8; CU8+GDR 17.0.4085.5</td>
</tr>
</tbody>
</table>
</figure>
<p>A CU (cumulative update) carries fixes and features. A GDR (General Distribution Release) carries security-only fixes for servers that don&#8217;t apply CUs. Each instance sits on one of two tracks, and the rule is one-way.</p>
<ol>
<li>GDR track: the instance has only had GDR updates and receives security fixes only (e.g., 17.0.1135.8 for SQL Server 2025).</li>
<li>CU track: the instance has had at least one CU. From here, security-only fixes arrive as a CU+GDR package built on the CU you already have (e.g., CU8+GDR 17.0.4085.5).</li>
<li>Moving from GDR to CU is allowed. Install a CU and the instance joins the CU track.</li>
<li>Moving from CU back to GDR is not allowed. Once a CU is installed, only CU and CU+GDR packages apply.</li>
</ol>
<p>Decide the track before the first patch. If you may want bug fixes or features later, plan to move to the CU track once and stay there. If you only want security fixes, stay on GDR until you are ready to commit to CUs.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">SELECT SERVERPROPERTY('ProductVersion') AS ProductVersion,
       SERVERPROPERTY('ProductLevel')   AS ProductLevel,
       SERVERPROPERTY('Edition')        AS Edition;</code></pre>
<p>Compare the returned build number with the table above to see which track and update level the instance is on.</p>
<h3>Is the Upgrade Worth It?</h3>
<p>Yes for write-heavy or backup-heavy systems, after testing. The practical 2025 gains are:</p>
<ul>
<li>Optimized locking, which uses transaction ID (TID) locking and lock after qualification (LAQ) to reduce blocking and lock memory, and to avoid lock escalation, with no application code changes.</li>
<li>Query Store on readable secondaries, on by default (in 2022 it was off by default and needed trace flag 12606).</li>
<li>ZSTD backup compression and backups on AG secondaries.</li>
<li>Resource Governor and higher limits in Standard edition.</li>
<li>tempdb space governance.</li>
</ul>
<p>The trade-off is testing effort. DiskANN vector indexes are preview only, so keep them out of production.</p>
<h3>What Breaks: Linked Servers and TLS</h3>
<p>A DBA report found that linked servers using SQLNCLI or MSOLEDBSQL can fail after an in-place upgrade to 2025, because of stricter TLS certificate validation. Setup can also restart the service up to three times, so the outage is longer than one restart. Fix the certificates or the provider settings before cutover, then test every linked server afterwards.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">DECLARE @name sysname;
DECLARE ls CURSOR FAST_FORWARD FOR
    SELECT name FROM sys.servers WHERE is_linked = 1;
OPEN ls;
FETCH NEXT FROM ls INTO @name;
WHILE @@FETCH_STATUS = 0
BEGIN
    BEGIN TRY
        EXEC sp_testlinkedserver @name;
        PRINT @name + ': OK';
    END TRY
    BEGIN CATCH
        PRINT @name + ': FAILED - ' + ERROR_MESSAGE();
    END CATCH;
    FETCH NEXT FROM ls INTO @name;
END;
CLOSE ls;
DEALLOCATE ls;</code></pre>
<p>The animated diagram below shows the safe order of steps, including the retry loop for failed linked server tests.</p>
<h3>In-Place vs Side-by-Side Migration</h3>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Approach</th>
<th>Rollback</th>
<th>Downtime</th>
<th>Effort</th>
</tr>
</thead>
<tbody>
<tr>
<td>In-place</td>
<td>Hard; restore the old setup from backups</td>
<td>Longer (service restarts during Setup)</td>
<td>Low</td>
</tr>
<tr>
<td>Side-by-side (backup/restore or log shipping cutover)</td>
<td>Easy; old server stays intact</td>
<td>Short, limited to the cutover</td>
<td>Higher</td>
</tr>
</tbody>
</table>
</figure>
<p>Use side-by-side for critical systems. Use in-place only for small or non-critical instances where a longer window is acceptable.</p>
<h3>After the Upgrade: Compatibility Level</h3>
<p>Keep the old compatibility level until the application is tested. Let Query Store capture baseline plans, then raise the level and force back any plan that regressed.</p>
<p>The level used below, 170 for SQL Server 2025, is not in the verified research for this article. Confirm it in the Microsoft documentation article &#8220;ALTER DATABASE compatibility level (Transact-SQL)&#8221; before you run the statement, and use the highest level that article lists for your version.</p>
<p class="code-label"><strong>T-SQL</strong></p>
<pre><code class="language-sql">-- Check the current level
SELECT name, compatibility_level FROM sys.databases WHERE name = N'SalesDb';

-- Raise it after testing. Replace 170 if the Microsoft docs list a different
-- level for your SQL Server 2025 build.
ALTER DATABASE SalesDb SET COMPATIBILITY_LEVEL = 170;</code></pre>
<h2 id="common-anti-patterns-and-sql-server-dba-production-readiness">Common Anti-Patterns and SQL Server DBA Production Readiness Checklist</h2>
<p>Most production incidents trace back to a small set of repeated mistakes. Use the table below to spot them, then run the checklist before a server goes live.</p>
<h3>Common SQL Server Anti-Patterns</h3>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Anti-pattern</th>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Default <code>max server memory</code></td>
<td>OS paging, memory pressure on the instance</td>
<td>Cap it, starting near 75% of available memory</td>
</tr>
<tr>
<td>MAXDOP 1 everywhere, or default 0 on a large NUMA box</td>
<td>Slow reports and index builds, or heavy parallel waits</td>
<td>Start from the NUMA-based guidance, scope per database</td>
</tr>
<tr>
<td>Cost threshold left at 5</td>
<td>Tiny queries going parallel</td>
<td>Raise it from observed plan costs, then check CXPACKET/CXCONSUMER</td>
</tr>
<tr>
<td>Single tempdb file, small autogrow steps</td>
<td>PAGELATCH contention, pauses during growth</td>
<td>Equally sized, pre-sized files with fixed growth</td>
</tr>
<tr>
<td>Clustered key on <code>NEWID()</code> GUID</td>
<td>Page splits, fragmentation</td>
<td>Use an ever-increasing key or <code>NEWSEQUENTIALID</code></td>
</tr>
<tr>
<td>Functions on indexed columns in <code>WHERE</code></td>
<td>Index scans instead of seeks</td>
<td>Rewrite as SARGable range predicates</td>
</tr>
<tr>
<td><code>SELECT *</code></td>
<td>Extra I/O, covering indexes unused</td>
<td>List only the needed columns</td>
</tr>
<tr>
<td>String-built dynamic SQL</td>
<td>Injection risk, plan cache bloat</td>
<td>Use <code>sp_executesql</code> with typed parameters</td>
</tr>
<tr>
<td>Apps connecting as <code>sa</code> or <code>db_owner</code></td>
<td>Large blast radius after a compromise</td>
<td>Custom role with <code>EXECUTE</code> on a schema</td>
</tr>
<tr>
<td><code>TrustServerCertificate=true</code> in production</td>
<td>No server identity check</td>
<td>Fix the certificate chain and host name</td>
</tr>
<tr>
<td>FULL recovery with no log backups</td>
<td>Log file grows until the disk fills</td>
<td>Schedule log backups, or use SIMPLE if the RPO allows</td>
</tr>
<tr>
<td>Backups never restored</td>
<td>Surprise failure at recovery time</td>
<td>Scheduled test restore plus <code>DBCC CHECKDB</code></td>
</tr>
<tr>
<td>Linked servers not tested after upgrade</td>
<td>Failures after cutover</td>
<td>Run <code>sp_testlinkedserver</code> on a copy first</td>
</tr>
</tbody>
</table>
</figure>
<h3>SQL Server Production Readiness Checklist</h3>
<p>This SQL Server DBA checklist combines the configuration, security and backup rules from the sections above. Work through it in order.</p>
<ol>
<li>Confirm the version and patch level, and choose the CU or GDR track on purpose.</li>
<li>Set the SQL Server max server memory setting to a specific value, with <code>min server memory</code> at 0.</li>
<li>Set MAXDOP and cost threshold for parallelism from the NUMA rule and measured plan costs.</li>
<li>Lay out tempdb with equally sized, pre-sized files on fast storage.</li>
<li>Separate data, log and tempdb volumes where the storage design allows.</li>
<li>Enable instant file initialization for data files.</li>
<li>Enable Query Store on user databases with a sensible size and capture mode.</li>
<li>Choose the isolation level deliberately, and test RCSI before turning it on.</li>
<li>Set connection strings with <code>Encrypt</code>, the listener name and <code>MultiSubnetFailover=True</code> where it applies.</li>
<li>Create least-privilege roles for each application, and disable or rename <code>sa</code>.</li>
<li>Enforce TLS in transit and enable TDE at rest.</li>
<li>Turn on SQL Server Audit for logins, permission changes and sensitive tables.</li>
<li>Schedule full, differential and log backups with <code>CHECKSUM</code>, compression and encryption.</li>
<li>Keep an offsite or immutable copy, and document backup keys and certificates.</li>
<li>Restore on a schedule and run <code>DBCC CHECKDB</code> on the restored copy.</li>
<li>Alert on wait trends, failed jobs, severity 17-25 errors and the age of the last good backup.</li>
<li>Write the upgrade plan, including linked server tests and the outage window.</li>
</ol>
<p>These items also form a short SQL Server security best practices review (items 10 to 12) and a SQL Server backup best practices review (items 13 to 15).</p>
<h2 id="key-takeaways">Key takeaways</h2>
<ul>
<li><strong>Cap max server memory:</strong> Start from about 75% of the memory that is available to SQL Server (after other processes and other instances), keep min server memory at 0, and always use a specific value if Lock Pages in Memory is granted.</li>
<li><strong>Tune MAXDOP and cost threshold by measurement:</strong> Begin with the Microsoft NUMA-based MAXDOP guidance, use database-scoped MAXDOP or Resource Governor for exceptions, and raise cost threshold for parallelism above the default of 5 after checking CXPACKET/CXCONSUMER waits.</li>
<li><strong>Use Strict or Mandatory encryption with a valid certificate:</strong> Fix the certificate chain or host name when connections fail after a driver upgrade. Avoid <code>TrustServerCertificate=true</code> in production.</li>
<li><strong>Give applications least privilege:</strong> Use a dedicated login and a role with EXECUTE on a schema, never <code>sa</code> or <code>db_owner</code>, and pass every value through typed parameters.</li>
<li><strong>Enable Query Store:</strong> Turn it on for every database you care about, so you can find regressions, force a known good plan, and keep baseline plans before a compatibility level change.</li>
<li><strong>Test restores, not just backups:</strong> Take backups with CHECKSUM, keep an offsite or immutable copy, and restore to a test name regularly, then run DBCC CHECKDB on the restored copy.</li>
<li><strong>Test the SQL Server 2025 upgrade on a copy first:</strong> Include linked servers, certificates and jobs, and run <code>sp_testlinkedserver</code> afterwards because stricter TLS validation can break them. Evaluate optimized locking with RCSI for write-heavy workloads, and keep DiskANN vector indexes out of production while they are preview only.</li>
</ul>
<p>That&#8217;s all! Happy coding!</p>
<p>Did this guide help you to set up and tune SQL Server for production? Please share your questions or experience in the comments below.</p>
<p>Bookmark this page for your next production review and share it with your team. Subscribe to TheCodeBuzz to get new database best-practice guides in your inbox.</p>
<h2 id="faq">FAQ</h2>
<h3>What should I set max server memory to in SQL Server?</h3>
<p>Start with about 75% of the memory that is available to the instance, meaning the total minus what the OS, other instances and other processes use, and leave min server memory at 0. The default is about 2 TB, which is effectively unlimited, so you should cap it on every version. If Lock Pages in Memory is granted to the service account, you must set a specific value, not the default. Set it with <code>sp_configure 'max server memory (MB)'</code> followed by <code>RECONFIGURE</code>, and check the result in <code>sys.configurations</code>.</p>
<h3>What is the best MAXDOP and cost threshold for parallelism setting?</h3>
<p>There is no single best value. Start from Microsoft&#8217;s NUMA-based MAXDOP guidance, set it per database with <code>ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP</code> for mixed workloads, and use Resource Governor workload groups where one workload needs a different limit. Avoid MAXDOP 1 server-wide, because it breaks parallel index builds and slows reporting queries. The cost threshold for parallelism defaults to 5, which is too low for modern hardware, so raise it based on observed plan costs and check the effect on CXPACKET and CXCONSUMER waits.</p>
<h3>How many tempdb files should I have, and how should I size them?</h3>
<p>Start with a small number of equally sized data files, up to the number of cores as Microsoft guidance suggests, and add more only if monitoring shows allocation contention. Pre-size the files and use a fixed growth increment, so autogrow stays an emergency measure and not a routine event. Equal sizes matter because SQL Server spreads allocations across files, and uneven files lead to uneven use. On SQL Server 2025 you can also use Resource Governor tempdb space governance, which stops a workload with an error when it exceeds its limit.</p>
<h3>Why does my app fail to connect after upgrading Microsoft.Data.SqlClient, with certificate errors?</h3>
<p>The driver now defaults to <code>Encrypt=Mandatory</code> and <code>TrustServerCertificate=false</code>, so it rejects a server certificate that the client does not trust. This is not a driver bug. Fix the certificate chain or make sure the host name in the connection string matches the certificate, instead of switching on <code>TrustServerCertificate=true</code>, which skips server identity checks. Use <code>Encrypt=Strict</code> where the server supports TDS 8.0, because Strict cannot be bypassed with <code>TrustServerCertificate</code>.</p>
<h3>Should I enable Query Store, optimized locking or Read Committed Snapshot Isolation?</h3>
<p>Yes to Query Store on every user database you care about, because it helps you find regressions and force a known good plan. Enable Read Committed Snapshot Isolation (RCSI) after testing, since it adds version storage and changes behavior for code that relied on blocking reads. Optimized locking comes with SQL Server 2025 and needs no application code changes. It works best together with RCSI, so it is a good reason to test 2025 for heavy write workloads.</p>
<h2 id="sources">Sources and further reading</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/sql/sql-server/what-s-new-in-sql-server-2025?view=sql-server-ver17" target="_blank" rel="noopener">What&#8217;s New in SQL Server 2025 &#8211; SQL Server</a> (Microsoft Learn)</li>
<li><a href="https://learn.microsoft.com/en-us/troubleshoot/sql/releases/download-and-install-latest-updates" target="_blank" rel="noopener">Latest Updates and Version History for SQL Server</a> (Microsoft Learn)</li>
<li><a href="https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/server-memory-server-configuration-options?view=sql-server-ver17" target="_blank" rel="noopener">Server Memory Configuration Options &#8211; SQL Server</a> (Microsoft Learn)</li>
<li><a href="https://learn.microsoft.com/en-us/sql/relational-databases/memory-management-architecture-guide?view=sql-server-ver17" target="_blank" rel="noopener">Memory Management Architecture Guide &#8211; SQL Server</a> (Microsoft Learn)</li>
<li><a href="https://learn.microsoft.com/en-us/sql/relational-databases/resource-governor/resource-governor?view=sql-server-ver17" target="_blank" rel="noopener">Resource Governor &#8211; SQL Server</a> (Microsoft Learn)</li>
<li><a href="https://learn.microsoft.com/en-us/sql/relational-databases/security/sql-server-security-best-practices?view=sql-server-ver17" target="_blank" rel="noopener">SQL Server Security Best Practices &#8211; SQL Server</a> (Microsoft Learn)</li>
<li><a href="https://learn.microsoft.com/en-us/sql/connect/ado-net/encryption-and-certificate-validation?view=sql-server-ver17" target="_blank" rel="noopener">Encryption and Certificate Validation in Microsoft.Data.SqlClient</a> (Microsoft Learn)</li>
<li><a href="https://techcommunity.microsoft.com/blog/azuresqlblog/whats-new-in-the-backuprestore-area-in-sql-server-2025/4474613" target="_blank" rel="noopener">Whats new in the Backup/Restore area in SQL Server 2025</a> (Microsoft Tech Community)</li>
<li><a href="https://www.mssqltips.com/sqlservertip/8290/sql-server-2025-new-features/" target="_blank" rel="noopener">SQL Server 2025 New Features and Enhancements</a> (MSSQLTips)</li>
<li><a href="https://www.sqlservercentral.com/articles/sql-server-2025-backup-compression-algorithm" target="_blank" rel="noopener">SQL Server 2025 Backup Compression Algorithm</a> (SQLServerCentral)</li>
</ul>
<p><em>Diagrams are original illustrations created by and for TheCodeBuzz.</em></p><p>The post <a href="https://thecodebuzz.com/sql-server-best-practices-guidelines/">SQL Server Best Practices and Guidelines-2026</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://thecodebuzz.com/sql-server-best-practices-guidelines/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>PostgreSQL &#8211; Best Practices for Production</title>
		<link>https://thecodebuzz.com/postgresql-best-practices-production/</link>
					<comments>https://thecodebuzz.com/postgresql-best-practices-production/#respond</comments>
		
		<dc:creator><![CDATA[Izzy]]></dc:creator>
		<pubDate>Thu, 08 Oct 2026 05:31:19 +0000</pubDate>
				<category><![CDATA[Database Best Practices]]></category>
		<category><![CDATA[High Availability]]></category>
		<category><![CDATA[Indexing]]></category>
		<category><![CDATA[pg_upgrade]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<guid isPermaLink="false">https://thecodebuzz.com/?p=30710</guid>

					<description><![CDATA[<p>Production PostgreSQL best practices: patching, upgrades from 14, indexing, UUIDv7 keys, checksums, OAuth and a readiness checklist you can apply today.</p>
<p>The post <a href="https://thecodebuzz.com/postgresql-best-practices-production/">PostgreSQL – Best Practices for Production</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>In our last article, we learned <a href="https://thecodebuzz.com/mongodb-security-guidelines-and-best-practices/">MongoDB specific guidelines</a>. Today in this article , we will learn about PostgreSQL.</p>
<p>&nbsp;</p>
<p>Most PostgreSQL outages and security incidents come from clusters that were never patched or never upgraded, not from exotic bugs. This guide is for engineers, architects and DBAs who run PostgreSQL in production. It is a compact reference you can bookmark and check a cluster against.</p>
<p>&nbsp;</p>
<p>Here is where things stand in October 2026. PostgreSQL 18 is the current stable major, and 18.6, dated 2026-08-13, is its latest minor. PostgreSQL 14 receives its last fixes on 2026-11-12, so anyone still on it has a deadline measured in weeks. I cover patching and upgrades first, because that is where most production risk sits.</p>
<p>After that come the PostgreSQL 18 features that change how you design schemas and indexes, with the reasoning behind each rule so you can judge where it applies to your workload. These PostgreSQL best practices start with a summary table and end with a checklist and FAQ.</p>
<p>&nbsp;</p>
<blockquote>
<p><strong>Version note:</strong> this guide targets PostgreSQL 18 (18.6), and also covers 17.11, 16.15, 15.19 and 14.24 for patching and upgrade planning, with PostgreSQL 19 Beta 4 mentioned only for testing.</p>
<p>&nbsp;</p>
</blockquote>
<h2 id="postgresql-best-practices-at-a-glance">PostgreSQL best practices at a glance</h2>
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Area</th>
<th>Do this</th>
<th>Avoid this</th>
</tr>
</thead>
<tbody>
<tr>
<td>Patching</td>
<td>Run the latest minor of your major: 18.6, 17.11, 16.15, 15.19 or 14.24. Minors contain only bug and security fixes, so they are low-risk, but still test them in staging first. They need a restart.</td>
<td>Pinning old minors. 18.1 fixed CVE-2025-12817 and 18.4 fixed CVE-2026-6575.</td>
</tr>
<tr>
<td>Versions</td>
<td>Run a supported major. PostgreSQL 14 receives its last fixes on November 12, 2026, so plan that upgrade now. Test your workload against PostgreSQL 19 Beta 4 in a non-production environment.</td>
<td>Running any beta in production, or staying on 14 after its end of support.</td>
</tr>
<tr>
<td>Backups and PITR</td>
<td>Take regular base backups and archive WAL continuously (<code>archive_mode</code> plus an archive command or library that reports failures). Rehearse restores to a chosen point in time, and monitor archive lag.</td>
<td>Relying on <code>pg_dump</code> alone for large production systems, or keeping backups you have never restored.</td>
</tr>
<tr>
<td>Replication and failover</td>
<td>Run at least one streaming standby in another failure domain. Use tested, automated failover tooling with fencing of the old primary. Choose <code>synchronous_commit</code> and synchronous standbys deliberately, based on how much data loss you can accept.</td>
<td>Untested manual failover, and abandoned replication slots that make the primary retain WAL until the disk fills.</td>
</tr>
<tr>
<td>Autovacuum and bloat</td>
<td>Keep autovacuum on. Tune per-table thresholds on hot, high-churn tables. Watch dead tuples, table and index size, and the oldest open transaction.</td>
<td>Disabling autovacuum, or leaving sessions idle in a transaction, which stops cleanup of dead rows.</td>
</tr>
<tr>
<td>Connection pooling</td>
<td>Put a pooler such as PgBouncer in front of the database and keep server-side connections to a small, measured number.</td>
<td>Thousands of direct application connections. Also avoid transaction-mode pooling with session state (session-level <code>SET</code>, advisory locks) without checking compatibility.</td>
</tr>
<tr>
<td>Memory and <code>max_connections</code></td>
<td>Start <code>shared_buffers</code> at roughly a quarter of RAM and adjust from measurements. Size <code>work_mem</code> against the worst-case number of concurrent sorts and hashes. Keep <code>max_connections</code> modest and rely on the pooler.</td>
<td>Raising <code>max_connections</code> to hide pool exhaustion, or a large global <code>work_mem</code> multiplied across many sessions.</td>
</tr>
<tr>
<td>TLS and SCRAM</td>
<td>Set <code>password_encryption = scram-sha-256</code>. Enable <code>ssl</code> and use <code>hostssl</code> lines in <code>pg_hba.conf</code> with <code>scram-sha-256</code>.</td>
<td><code>trust</code> rules, unencrypted remote connections and legacy <code>md5</code> hashes.</td>
</tr>
<tr>
<td>Authentication</td>
<td>Where you have an identity provider, configure OAuth in <code>pg_hba.conf</code> and load validators with <code>oauth_validator_libraries</code>.</td>
<td>Shared static passwords.</td>
</tr>
<tr>
<td>Monitoring</td>
<td>Load <code>pg_stat_statements</code> through <code>shared_preload_libraries</code>. Set <code>log_min_duration_statement</code> to a threshold that fits your workload and enable <code>log_lock_waits</code>. Alert on replication lag, archive failures, disk space and connection saturation.</td>
<td>Logging every statement permanently, or finding slow queries only after users report them.</td>
</tr>
<tr>
<td>Primary keys</td>
<td>Use <code>uuidv7()</code> for timestamp-ordered keys.</td>
<td>Random UUIDs as keys on large B-tree indexes, because of poor locality.</td>
</tr>
<tr>
<td>Composite indexes</td>
<td>Order columns for your main queries. PostgreSQL 18 skip scan lets a query omit an equality condition on leading columns. It helps most when the omitted leading column has few distinct values. Confirm with <code>EXPLAIN</code>.</td>
<td>Dropping or reordering existing indexes because skip scan exists, or adding redundant indexes without checking plans.</td>
</tr>
<tr>
<td>Integrity</td>
<td>Use <code>WITHOUT OVERLAPS</code> and <code>PERIOD</code> temporal constraints.</td>
<td>Application-side overlap checks.</td>
</tr>
<tr>
<td>Storage</td>
<td>Keep data checksums, which <code>initdb</code> enables by default in 18. When upgrading a cluster that has none, <code>pg_upgrade</code> needs matching settings, so initialize the new cluster with <code>--no-data-checksums</code>.</td>
<td>Turning checksums off without a reason.</td>
</tr>
<tr>
<td>I/O</td>
<td>Use 18 async I/O (<code>io_method</code>) for reads. Inspect <code>pg_aios</code>. Benchmark on your own storage before changing the default.</td>
<td>Changing <code>io_method</code> on assumptions.</td>
</tr>
</tbody>
</table>
</figure>
<p>Minor releases contain only bug and security fixes, which is why they are low-risk to apply. Low-risk is not no-risk: read the release notes, test against your workload and schedule the restart. The CVE examples above show why you should not defer them for long.</p>
<p>&nbsp;</p>
<p>Key order matters because a B-tree keeps neighbouring values on neighbouring pages. Time-ordered keys append to the right edge of the index, while random keys touch pages all over it, which hurts cache hit rates on large tables.</p>
<p>&nbsp;</p>
<p>Skip scan changes the column-order trade-off but does not remove it. PostgreSQL has to probe the index once for each distinct value of the omitted leading column. With a handful of values that is cheap.</p>
<p>&nbsp;</p>
<p>With millions of values it is not, and an index that leads with the column you actually filter on is still the better choice. Check the plan before you change any existing index.</p>
<h3> </h3>
<h3>Example: uuidv7 key, temporal constraint and a skip scan candidate</h3>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE room_booking (
    booking_id uuid NOT NULL DEFAULT uuidv7() PRIMARY KEY,
    tenant_id  integer NOT NULL,
    room_id    integer NOT NULL,
    status     text NOT NULL,   -- few distinct values, for example 'confirmed' or 'cancelled'
    during     tstzrange NOT NULL,
    CONSTRAINT no_double_booking
        UNIQUE (room_id, during WITHOUT OVERLAPS)
);

-- Leading column has few distinct values; queries may filter on tenant_id alone.
CREATE INDEX room_booking_status_tenant_idx
    ON room_booking (status, tenant_id);</code></pre>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>The scalar <code>room_id</code> in the temporal key needs <code>btree_gist</code>. The range column <code>during</code> is what the constraint checks for overlap. The index behind that constraint also starts with <code>room_id</code>, so lookups by room already have an index and you do not need to add another.</p>
<p>&nbsp;</p>
<p>The <code>(status, tenant_id)</code> index is the skip scan candidate. The query below has an equality condition on <code>tenant_id</code> but none on the leading column <code>status</code>. Before PostgreSQL 18, this index could not serve it efficiently. Run it against representative data after an <code>ANALYZE</code>. On a tiny table the planner may prefer a sequential scan, so the <code>SET LOCAL</code> line is for testing only and shows whether the index is usable at all.</p>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">ANALYZE room_booking;

BEGIN;
SET LOCAL enable_seqscan = off;   -- test only: see whether the index can serve the query

EXPLAIN (ANALYZE, BUFFERS)
SELECT booking_id
FROM room_booking
WHERE tenant_id = 42;

ROLLBACK;</code></pre>
<p>In the plan, look for a scan on <code>room_booking_status_tenant_idx</code> with <code>tenant_id = 42</code> as the index condition, even though the query never mentions <code>status</code>.</p>
<p>PostgreSQL 18 also reports the number of index searches in <code>EXPLAIN ANALYZE</code>. A skip scan shows roughly one search per distinct <code>status</code> value.</p>
<p>Then rerun the query without the <code>SET LOCAL</code> line and compare timing and buffer counts with the plan the planner picks on its own. If the planner chooses a sequential scan or another index and is faster, keep that plan. If <code>status</code> has many distinct values in your data, a separate index on <code>(tenant_id)</code> is the better design.</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<h2 id="plan-upgrades-and-the-production-readiness-checklist">Plan upgrades and the production readiness checklist</h2>
<h3>Support lifecycle</h3>
<p>PostgreSQL ships a major version about once a year and supports each for five years. PostgreSQL 18 was first released on 2025-09-25, and its final release is due 2030-11-14. PostgreSQL 14 stops receiving fixes on 2026-11-12, so schedule that move now. Since version 10, minors increment the second number (18.5 to 18.6).</p>
<p>&nbsp;</p>
<p>Minor releases carry only bug and security fixes, so they are low-risk to apply, but not zero-risk. Test them in staging and keep a rollback plan. Apply them promptly. Release 18.1 fixed CVE-2025-12817, where <code>CREATE STATISTICS</code> skipped a schema <code>CREATE</code> privilege check.</p>
<p>&nbsp;</p>
<p>Release 18.4 fixed CVE-2026-6575 in <code>pg_restore_attribute_stats</code> along with more than 60 bugs. As of early October 2026 the newest minors are 18.6, 17.11, 16.15, 15.19 and 14.24, all dated 2026-08-13.</p>
<p>&nbsp;</p>
<p>PostgreSQL 19 is in beta (Beta 4 was announced on 2026-09-24). Do not run it in production, but do test your own workloads against it so you find regressions early.</p>
<p>&nbsp;</p>
<h3>Upgrading with pg_upgrade</h3>
<p>PostgreSQL 18 keeps planner statistics across the upgrade, runs checks in parallel with <code>--jobs</code>, and adds <code>--swap</code>, which swaps directories instead of copying, cloning or linking. The animated diagram below walks through this path.</p>
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/postgresql-best-practices-production-diagram-2.gif" alt="Decision path for upgrading PostgreSQL 14: check checksums, run initdb, pg_upgrade --check, --swap, analyze, then a supported major." width="1200" height="675" />
<figcaption>Match the new cluster&#8217;s checksum setting to the old one, dry-run with &#8211;check, then upgrade with &#8211;swap after a verified backup.</figcaption>
</figure>
<p>Four rules govern the new cluster and the run itself:</p>
<p>&nbsp;</p>
<ul>
<li><strong>Compatible encoding and locale.</strong> Initialise the new cluster with an encoding and locale that match the old one. <code>pg_upgrade</code> refuses to proceed if they differ. Read the old values first and pass them to <code>initdb</code> explicitly rather than relying on the environment.</li>
<li><strong>Matching checksum settings.</strong> <code>initdb</code> in PostgreSQL 18 enables data checksums by default, and <code>pg_upgrade</code> requires both clusters to agree. If the old cluster has none, create the new one with <code>--no-data-checksums</code>. You can enable checksums later, offline, with <code>pg_checksums --enable</code> while the cluster is cleanly shut down. It rewrites every data file, so plan the window.</li>
<li><strong>Run as the <code>postgres</code> user.</strong> Run <code>initdb</code> and <code>pg_upgrade</code> as the operating-system user that owns the data directories, normally <code>postgres</code>. Running as root, or as a different user, causes permission failures or leaves files with the wrong owner.</li>
<li><strong><code>--swap</code> leaves the old cluster unusable.</strong> It reuses the old cluster&#8217;s files instead of copying them, so you cannot start the old cluster afterwards. Your rollback is the backup you took before the upgrade, so confirm it restores before you begin.</li>
</ul>
<p>&nbsp;</p>
<p class="code-label"><strong>SQL</strong></p>
<p>&nbsp;</p>
<pre><code class="language-sql">-- Run on the OLD cluster: encoding and locale to reuse for initdb
SELECT datname,
       pg_encoding_to_char(encoding) AS encoding,
       datcollate,
       datctype
FROM pg_database
WHERE datname NOT IN ('template0', 'template1');</code></pre>
<p class="code-label"><strong>Bash/shell</strong></p>
<pre><code class="language-bash"># Run everything as the postgres user
sudo -iu postgres

# Does the old cluster use checksums?
pg_controldata -D /var/lib/postgresql/14/main | grep -i checksum

# New cluster: match encoding and locale from the query above.
# Add --no-data-checksums ONLY if the old cluster has no checksums.
/usr/lib/postgresql/18/bin/initdb \
  --encoding=UTF8 --locale=en_US.UTF-8 \
  --no-data-checksums \
  -D /var/lib/postgresql/18/main

# Dry run: checks only, changes nothing
/usr/lib/postgresql/18/bin/pg_upgrade --check --jobs=4 \
  -b /usr/lib/postgresql/14/bin -B /usr/lib/postgresql/18/bin \
  -d /var/lib/postgresql/14/main -D /var/lib/postgresql/18/main

# Real run, after a verified backup. The old cluster is unusable afterwards.
/usr/lib/postgresql/18/bin/pg_upgrade --jobs=4 --swap \
  -b /usr/lib/postgresql/14/bin -B /usr/lib/postgresql/18/bin \
  -d /var/lib/postgresql/14/main -D /var/lib/postgresql/18/main

# Optional, later, with the cluster cleanly shut down:
# pg_checksums --enable -D /var/lib/postgresql/18/main</code></pre>
<h3> </h3>
<h3> </h3>
<h3>After the upgrade</h3>
<p>&nbsp;</p>
<p>Do not reopen the application to traffic until you have checked three things.</p>
<ol>
<li>Start the new cluster and confirm the version with <code>SELECT version();</code>.</li>
<li>Check statistics. PostgreSQL 18 carries planner statistics across, which shortens the slow period after an upgrade, but verify rather than assume. Find tables that have rows but no statistics, then run <code>ANALYZE</code>.</li>
<li>Run your application&#8217;s test suite and a representative query set against the new cluster. Compare the plans and timings of your slowest queries with a pre-upgrade baseline.</li>
</ol>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">-- Tables that have rows but no planner statistics
SELECT c.oid::regclass AS table_name, c.reltuples::bigint AS est_rows
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
  AND c.reltuples &gt; 0
  AND NOT EXISTS (
    SELECT 1 FROM pg_stats s
    WHERE s.schemaname = n.nspname AND s.tablename = c.relname
  );</code></pre>
<p class="code-label"><strong>Bash/shell</strong></p>
<pre><code class="language-bash"># Refresh statistics in every database
vacuumdb --all --analyze-only --jobs=4</code></pre>
<h3> </h3>
<h3>Low-downtime alternative: logical replication</h3>
<p>If you cannot afford the downtime of <code>pg_upgrade</code>, build a new cluster on the target major version and keep it in sync with logical replication. Then switch applications over in a short window. PostgreSQL 17 improved logical replication for high-availability setups and major-version upgrades, so this path is better supported on recent versions. It also gives you a rollback: the old cluster stays intact until you decide to retire it.</p>
<p>The cost is more moving parts. Logical replication does not copy schema changes, so you must create the schema on the target and freeze DDL during the migration.</p>
<p>Sequence values need to be synchronised before cutover.</p>
<p>Tables need suitable replica identities, usually a primary key. Rehearse the whole procedure on a copy of production first.</p>
<h3> </h3>
<h3>Production readiness checklist</h3>
<p>&nbsp;</p>
<p>Each item names something you can check. If you cannot show the evidence, treat the item as not done.</p>
<ol>
<li><strong>Latest minor applied.</strong> <code>SHOW server_version;</code> matches the newest minor of your major (18.6, 17.11, 16.15, 15.19 or 14.24 as of this writing).</li>
<li><strong>Supported major with a dated plan.</strong> You are not on a version past its end-of-fix date. If you run PostgreSQL 14, the upgrade must finish before 2026-11-12.</li>
<li><strong>WAL archiving works.</strong> <code>archive_mode = on</code> with a working archive command or library. <code>pg_stat_archiver</code> shows a recent <code>last_archived_time</code> and a <code>failed_count</code> that is not growing. Alert when archiving fails, because unarchived WAL piles up on the primary&#8217;s disk.</li>
<li><strong>A restore has been tested.</strong> You have restored a base backup plus archived WAL to a separate host, reached a chosen point in time, and run application queries against it. Record the date and how long it took, and repeat on a schedule.</li>
<li><strong><code>pg_stat_statements</code> is enabled.</strong> It is in <code>shared_preload_libraries</code> (this needs a restart), and <code>CREATE EXTENSION pg_stat_statements;</code> has been run in the database you query. A dashboard or a regular review uses it to find the slowest and most frequent statements.</li>
<li><strong>Autovacuum is monitored.</strong> You alert on dead tuples and time since the last autovacuum for large tables, on long-running transactions and stale replication slots that hold back cleanup, and on transaction ID age (<code>age(datfrozenxid)</code>) approaching the freeze limits.</li>
<li><strong>TLS is required.</strong> <code>ssl = on</code> with a valid certificate, and <code>pg_hba.conf</code> uses <code>hostssl</code> entries rather than plain <code>host</code> for network clients. Clients connect with <code>sslmode=verify-full</code> where possible.</li>
<li><strong>Passwords use <code>scram-sha-256</code>.</strong> <code>password_encryption = scram-sha-256</code>, and no line in <code>pg_hba.conf</code> uses <code>md5</code> or <code>trust</code> for network access. Existing <code>md5</code> hashes cannot be converted, so reset those passwords after changing the setting.</li>
<li><strong>Least-privilege roles.</strong> Applications do not connect as a superuser or as the schema owner. Check with <code>\du</code> and by reviewing grants.</li>
<li><strong>Replication lag has thresholds.</strong> Derive them from your recovery point objective: one warning level and one paging level, measured on <code>pg_stat_replication</code> (<code>replay_lag</code> or byte lag) and on standbys. Alert also when a standby disconnects entirely, since a missing row looks like zero lag.</li>
<li><strong>Failover has been exercised.</strong> You have promoted a standby on purpose and confirmed that applications reconnect.</li>
<li><strong>Data checksums are on, or the plan is written down.</strong> New PostgreSQL 18 clusters have them by default. For older clusters, schedule an offline <code>pg_checksums --enable</code>.</li>
<li><strong>Disk and connection headroom are alerted.</strong> You get a warning before the data, WAL or archive volumes fill, and before connection counts approach <code>max_connections</code>.</li>
</ol>
<p class="code-label"><strong>Text</strong></p>
<pre><code class="language-text"># pg_hba.conf: TLS required, SCRAM only, no trust/md5 for network clients
# TYPE     DATABASE  USER  ADDRESS        METHOD
local      all       postgres              peer
hostssl    all       all   10.0.0.0/16    scram-sha-256
hostnossl  all       all   0.0.0.0/0      reject</code></pre>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">-- WAL archiving: failures should be zero and last_archived_time recent
SELECT archived_count, failed_count, last_archived_time, last_failed_time
FROM pg_stat_archiver;

-- Replication lag per standby (run on the primary)
SELECT application_name, state, replay_lag,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication;

-- Autovacuum: tables with the most dead tuples
SELECT relname, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

-- Transaction ID age per database
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY xid_age DESC;

-- Auth and TLS settings
SHOW password_encryption;
SHOW ssl;</code></pre>
<h2 id="key-takeaways">Key takeaways</h2>
<ul>
<li><strong>Patch minors promptly.</strong> Run the latest minor of your major version. Minors carry only bug and security fixes, so they are low-risk, but still roll them out through staging first.</li>
<li><strong>Retire PostgreSQL 14 now.</strong> Set an upgrade date before its final fixes arrive in November 2026, and prefer PostgreSQL 18 as the target.</li>
<li><strong>Use <code>uuidv7()</code> for UUID primary keys.</strong> Timestamp-ordered values keep inserts localized in the index.</li>
<li><strong>Recheck composite index column order.</strong> Skip scan in PostgreSQL 18 may make some indexes redundant, so confirm with query plans.</li>
<li><strong>Enforce integrity in the database.</strong> Use temporal constraints instead of application-side overlap checks, and keep data checksums enabled.</li>
<li><strong>Rehearse <code>pg_upgrade</code>.</strong> Run <code>pg_upgrade --check</code> first and take a backup before using <code>--swap</code>.</li>
<li><strong>Prefer OAuth over shared static passwords, and benchmark <code>io_method</code> yourself.</strong> Inspect <code>pg_aios</code> on your own storage before changing the default.</li>
<li><strong>Keep PostgreSQL 19 beta out of production.</strong> Use it in test environments to try your own workloads.</li>
</ul>
<h2 id="faq">FAQ</h2>
<h3>Should I upgrade to PostgreSQL 18 or 17?</h3>
<p>Choose 18 unless something concrete blocks it, such as an extension, driver or managed service that does not support it yet. PostgreSQL 18 was first released on 2025-09-25 and is scheduled for final fixes on 2030-11-14, so it gives you the longest support window. Upgrading to 17 means you will have to run another major upgrade sooner.</p>
<p>PostgreSQL 18 also makes the upgrade itself easier:</p>
<ul>
<li>Planner statistics carry over, so the new cluster reaches normal performance sooner.</li>
<li><code>pg_upgrade</code> runs its checks in parallel with <code>--jobs</code>.</li>
<li>The new <code>--swap</code> flag swaps directories instead of copying, cloning or linking files, which helps most when a database has many objects.</li>
</ul>
<p>PostgreSQL 17 is a reasonable stop if your dependencies force it. It reworked vacuum memory management, improved high-concurrency workloads and bulk loading, and improved logical replication for high availability and major upgrades. Do not pick 16 or 15 as a target for a new upgrade. They have less support time left and you would repeat the work soon.</p>
<p>Either way, restore a production backup into a staging cluster, run <code>pg_upgrade</code> there, and replay your real workload before you commit. If you are still on 14, its last fixes are due on 2026-11-12, so schedule this now.</p>
<p>&nbsp;</p>
<h3>Can I enable data checksums on an existing cluster?</h3>
<p>Yes, but not online. <code>initdb</code> in PostgreSQL 18 enables checksums by default, which does nothing for clusters that already exist. First check your current state:</p>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">SHOW data_checksums;<br /></code></pre>
<p>If it returns <code>off</code>, the usual route is the <code>pg_checksums</code> tool. It requires a cleanly shut-down cluster and rewrites every data file to add checksums, so downtime grows with database size. Run it on a restored copy first to measure that time. If you cannot afford the downtime, build a new cluster with checksums enabled and move the data over with logical replication.</p>
<p>The setting matters for major upgrades because <code>pg_upgrade</code> requires the old and new clusters to match. If your old cluster has no checksums and you do not want to change that during the upgrade, initialize the new 18 cluster without them:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p class="code-label"><strong>Bash</strong></p>
<pre><code class="language-bash">initdb --no-data-checksums -D /var/lib/postgresql/18/main
pg_upgrade \
  --old-bindir=/usr/lib/postgresql/16/bin \
  --new-bindir=/usr/lib/postgresql/18/bin \
  --old-datadir=/var/lib/postgresql/16/main \
  --new-datadir=/var/lib/postgresql/18/main \
  --jobs=4 --check</code></pre>
<p>Dropping <code>--check</code> runs the real upgrade. I recommend treating the upgrade and the checksum change as two separate maintenance steps. If something goes wrong, you then know which change caused it. The trade-off is that checksums add a small CPU cost, but they let PostgreSQL detect storage-level corruption instead of silently reading bad pages.</p>
<h3>Should I use uuidv7() on existing tables?</h3>
<p>Use it as the default for new rows and leave existing keys alone. <code>uuidv7()</code> is new in PostgreSQL 18 and generates timestamp-ordered UUIDs. New values are inserted near the end of a primary key index, which suits indexes and caching better than random UUIDs. Changing the default does not rewrite or invalidate existing rows, because both kinds of value are ordinary <code>uuid</code> values:</p>
<p class="code-label"><strong>SQL</strong></p>
<pre><code class="language-sql">ALTER TABLE orders
  ALTER COLUMN id SET DEFAULT uuidv7();</code></pre>
<p>Do not rewrite existing primary keys just to get ordering. That would mean updating every foreign key and external reference, and the benefit applies only to new inserts. Two caveats apply:</p>
<ul>
<li>A UUIDv7 embeds its creation time, so do not use it where exposing creation time to clients is a problem.</li>
<li>The function does not exist before PostgreSQL 18. If any replica or application environment still runs an older major, applying the default there will fail.</li>
</ul>
<p>Apply the change after every node runs 18. <code>uuidv4()</code> is also new in 18 as an alias for <code>gen_random_uuid()</code>, if you want a name that states the version.</p>
<h3>Do multicolumn indexes need to be redesigned for PostgreSQL 18?</h3>
<p>Not immediately. PostgreSQL 18 adds skip scan on multicolumn B-tree indexes, so a query that omits an equality condition on one or more leading columns can still use the index. That can make some single-purpose indexes redundant, and it relaxes how strictly you must order columns in new composite indexes.</p>
<p>Do not drop indexes on this basis alone. Compare the plans and timings of your real queries after the upgrade, and only then retire an index you can show is unused or covered. Dropping an index in a hurry is harder to undo than keeping it.</p>
<p>&nbsp;</p>
<h3>Is it safe to apply PostgreSQL minor releases without a long test cycle?</h3>
<p>Minor releases carry only bug and security fixes, which makes them low-risk, but not risk-free. Skipping them has a real cost: 18.1 fixed CVE-2025-12817, where <code>CREATE STATISTICS</code> skipped a schema <code>CREATE</code> privilege check, and 18.4 fixed CVE-2026-6575 in <code>pg_restore_attribute_stats</code> along with over 60 bugs.</p>
<p>Roll the update through staging first and confirm you have a backup you have actually restored. Then move production to the latest minor of your major: 18.6, 17.11, 16.15, 15.19 or 14.24. Since version 10, minor releases change the second number (for example 10.0 to 10.1), so 18.5 to 18.6 is a minor update.</p>
<h3>Should I test PostgreSQL 19 now?</h3>
<p>Yes, run your own workload against it in a non-production environment. Beta 4 was announced on 2026-09-24, and the project encourages this kind of testing, so problems can be reported before the final release. Do not run a beta in production. For production, use PostgreSQL 18.</p>
<h2 id="sources">Sources and further reading</h2>
<ul>
<li><a href="https://www.postgresql.org/" target="_blank" rel="noopener">PostgreSQL: The world&#8217;s most advanced open source database</a> (PostgreSQL Global Development Group)</li>
<li><a href="https://www.postgresql.org/support/versioning/" target="_blank" rel="noopener">PostgreSQL: Versioning Policy</a> (PostgreSQL Global Development Group)</li>
<li><a href="https://www.postgresql.org/about/news/postgresql-18-released-3142/" target="_blank" rel="noopener">PostgreSQL: PostgreSQL 18 Released!</a> (PostgreSQL Global Development Group)</li>
<li><a href="https://www.postgresql.org/docs/release/18.0/" target="_blank" rel="noopener">PostgreSQL 18.0 Release Notes</a> (PostgreSQL Global Development Group)</li>
</ul>
<p>&nbsp;</p>


<p class="wp-block-paragraph">Please share this article with your friends and <a rel="noreferrer noopener" href="https://www.thecodebuzz.com/subscription/" target="_blank"><em><strong>Subscribe</strong> </em></a>to the blog to get a notification on freshly published best practices of software development.</p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph">        </p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph"></p><p>The post <a href="https://thecodebuzz.com/postgresql-best-practices-production/">PostgreSQL – Best Practices for Production</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://thecodebuzz.com/postgresql-best-practices-production/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Monolith First: Why Startups Should Skip Microservices</title>
		<link>https://thecodebuzz.com/monolith-first-startups-skip-microservices/</link>
					<comments>https://thecodebuzz.com/monolith-first-startups-skip-microservices/#respond</comments>
		
		<dc:creator><![CDATA[Izzy]]></dc:creator>
		<pubDate>Thu, 08 Oct 2026 02:55:53 +0000</pubDate>
				<category><![CDATA[Startups]]></category>
		<category><![CDATA[.net]]></category>
		<category><![CDATA[architecture]]></category>
		<category><![CDATA[microservices]]></category>
		<category><![CDATA[modular monolith]]></category>
		<category><![CDATA[startups]]></category>
		<guid isPermaLink="false">https://thecodebuzz.com/?p=30705</guid>

					<description><![CDATA[<p>Learn why most startups should build a modular monolith first, how to structure it in C#/.NET, and when to extract a service. Includes code and a checklist.</p>
<p>The post <a href="https://thecodebuzz.com/monolith-first-startups-skip-microservices/">Monolith First: Why Startups Should Skip Microservices</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>Fowler&#8217;s 2015 &#8220;MonolithFirst&#8221; note observed that almost every successful microservice system he knew of began as a monolith that grew too big, and that most systems built as microservices from scratch ran into serious trouble.</p>
<p>&nbsp;</p>
<p>That is anecdote, not measured data. It still points to a lower-risk start: one deployable unit with strict internal modules. This article shows how to build one in .NET, how to keep its boundaries honest, and when to extract a service.</p>
<p>&nbsp;</p>
<h2>Why start with a monolith</h2>
<ul>
<li><strong>Unstable boundaries:</strong> a new domain keeps changing. Early splits create arbitrary seams in a distributed system.</li>
<li><strong>Operational load:</strong> microservices add service discovery, tracing, inter-service auth, gateways and orchestration. A monolith has one pipeline, one log destination and one process to debug.</li>
<li> </li>
</ul>
<table>
<thead>
<tr>
<th> </th>
<th>Monolith</th>
<th>Modular monolith</th>
<th>Microservices</th>
</tr>
</thead>
<tbody>
<tr>
<td>Deployables</td>
<td>1</td>
<td>1</td>
<td>Many</td>
</tr>
<tr>
<td>Debugging</td>
<td>One process</td>
<td>One process</td>
<td>Cross-network, needs tracing</td>
</tr>
<tr>
<td>Consistency</td>
<td>One transaction possible</td>
<td>Transactions inside a module; across modules, an outbox and eventual consistency</td>
<td>Eventual consistency</td>
</tr>
<tr>
<td>Moving boundaries</td>
<td>Cheap, easy to tangle</td>
<td>Cheap</td>
<td>Expensive</td>
</tr>
</tbody>
</table>
<p>One 2026 vendor guide suggests a modular monolith under about 15 developers and microservices at 50 or more.</p>
<p>Those are heuristics, not rules. A 2014 article, &#8220;Don&#8217;t start with a monolith,&#8221; argued that a team that can&#8217;t build a well-structured monolith can&#8217;t build well-structured services. The real risk is poor modularity.</p>
<figure class="wp-block-image size-large"><img decoding="async" src="https://thecodebuzz.com/wp-content/uploads/2026/10/monolith-first-startups-skip-microservices-2.jpg" alt="engineer, engineering, structural engineer" />
<figcaption>Image by <a href="https://pixabay.com/users/This_is_Engineering-11384528/" target="_blank" rel="noopener nofollow">This_is_Engineering</a> from <a href="https://pixabay.com/photos/engineer-engineering-4925116/" target="_blank" rel="noopener nofollow">Pixabay</a></figcaption>
</figure>
<h2>Build a modular monolith in .NET</h2>
<p>Requires .NET 8+ (C# 12 primary constructors). Packages: <code>Microsoft.EntityFrameworkCore.SqlServer</code>, <code>Microsoft.Extensions.Http.Resilience</code>, xUnit. Each class library needs usings for <code>Microsoft.EntityFrameworkCore</code>, <code>Microsoft.Extensions.DependencyInjection</code>, <code>Microsoft.Extensions.Hosting</code>, <code>Microsoft.Extensions.Logging</code>, <code>System.Text.Json</code> and <code>System.Net.Http.Json</code> as needed.</p>
<p>&nbsp;</p>
<pre><code class="language-text">src/
  Host/               # minimal API; references modules and contracts
  SharedKernel/       # IDomainEvent, IEventHandler, event bus
  Orders.Contracts/   # IOrdersApi, DTOs, OrderPlaced (public)
  Orders/             # internal DbContext, logic
  Billing/            # references Orders.Contracts only
tests/ArchitectureTests/</code></pre>
<p>Modules talk through contracts or events.</p>
<p>Each module has its own DbContext and schema, so there is no automatic transaction across modules.</p>
<p>To avoid losing events, Orders writes the event to an outbox table in the same <code>SaveChanges</code> as the order.</p>
<p>A background dispatcher publishes it afterwards.</p>
<p>Delivery is at-least-once, so handlers must be idempotent. A broker will give you the same contract later.</p>
<p>&nbsp;</p>
<pre><code class="language-csharp">// SharedKernel
public interface IDomainEvent { }
public interface IEventHandler&lt;in T&gt; where T : IDomainEvent
{
    Task HandleAsync(T e, CancellationToken ct);
}
public sealed class InProcessEventBus(IServiceProvider sp)
{
    public async Task PublishAsync&lt;T&gt;(T e, CancellationToken ct) where T : IDomainEvent
    {
        foreach (var h in sp.GetServices&lt;IEventHandler&lt;T&gt;&gt;())
            await h.HandleAsync(e, ct);
    }
}

// Orders.Contracts
public sealed record OrderDto(Guid Id, decimal Total, string Status);
public sealed record OrderPlaced(Guid OrderId, decimal Total) : IDomainEvent;
public interface IOrdersApi
{
    Task&lt;OrderDto?&gt; GetOrderAsync(Guid id, CancellationToken ct);
    Task&lt;Guid&gt; PlaceOrderAsync(decimal total, CancellationToken ct);
}

// Orders (internal)
internal sealed class OrderEntity
{
    public Guid Id { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; } = "Placed";
}
internal sealed class OutboxMessage
{
    public Guid Id { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public string Payload { get; set; } = "";
    public DateTime? ProcessedAt { get; set; }
}
internal sealed class OrdersDbContext(DbContextOptions&lt;OrdersDbContext&gt; options) : DbContext(options)
{
    public DbSet&lt;OrderEntity&gt; Orders =&gt; Set&lt;OrderEntity&gt;();
    public DbSet&lt;OutboxMessage&gt; Outbox =&gt; Set&lt;OutboxMessage&gt;();
    protected override void OnModelCreating(ModelBuilder b) =&gt; b.HasDefaultSchema("orders");
}
internal sealed class OrdersApi(OrdersDbContext db) : IOrdersApi
{
    public Task&lt;OrderDto?&gt; GetOrderAsync(Guid id, CancellationToken ct) =&gt;
        db.Orders.AsNoTracking().Where(o =&gt; o.Id == id)
            .Select(o =&gt; new OrderDto(o.Id, o.Total, o.Status))
            .FirstOrDefaultAsync(ct);

    public async Task&lt;Guid&gt; PlaceOrderAsync(decimal total, CancellationToken ct)
    {
        var order = new OrderEntity { Id = Guid.NewGuid(), Total = total };
        db.Orders.Add(order);
        db.Outbox.Add(new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, total))
        });
        await db.SaveChangesAsync(ct); // order and event commit together
        return order.Id;
    }
}
internal sealed class OutboxDispatcher(IServiceScopeFactory scopes, ILogger&lt;OutboxDispatcher&gt; log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
        while (await timer.WaitForNextTickAsync(ct))
        {
            using var scope = scopes.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService&lt;OrdersDbContext&gt;();
            var bus = scope.ServiceProvider.GetRequiredService&lt;InProcessEventBus&gt;();
            var pending = await db.Outbox.Where(m =&gt; m.ProcessedAt == null)
                .OrderBy(m =&gt; m.CreatedAt).Take(20).ToListAsync(ct);
            foreach (var m in pending)
            {
                try
                {
                    await bus.PublishAsync(JsonSerializer.Deserialize&lt;OrderPlaced&gt;(m.Payload)!, ct);
                    m.ProcessedAt = DateTime.UtcNow;
                    await db.SaveChangesAsync(ct);
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    log.LogError(ex, "Outbox message {Id} failed; will retry", m.Id);
                }
            }
        }
    }
}
public static class OrdersModule
{
    public static IServiceCollection AddOrdersModule(this IServiceCollection s, string cs)
    {
        s.AddDbContext&lt;OrdersDbContext&gt;(o =&gt;
            o.UseSqlServer(cs, sql =&gt; sql.MigrationsHistoryTable("__EFMigrationsHistory", "orders")));
        s.AddScoped&lt;IOrdersApi, OrdersApi&gt;();
        s.AddHostedService&lt;OutboxDispatcher&gt;();
        return s;
    }
}</code></pre>
<p>Billing reacts to <code>OrderPlaced</code> and sees only <code>Orders.Contracts</code>:</p>
<pre><code class="language-csharp">// Billing (internal; references Orders.Contracts and SharedKernel)
internal sealed class Invoice { public Guid OrderId { get; set; } public decimal Amount { get; set; } }
internal sealed class BillingDbContext(DbContextOptions&lt;BillingDbContext&gt; options) : DbContext(options)
{
    public DbSet&lt;Invoice&gt; Invoices =&gt; Set&lt;Invoice&gt;();
    protected override void OnModelCreating(ModelBuilder b)
    {
        b.HasDefaultSchema("billing");
        b.Entity&lt;Invoice&gt;().HasKey(i =&gt; i.OrderId);
    }
}
internal sealed class CreateInvoiceOnOrderPlaced(BillingDbContext db) : IEventHandler&lt;OrderPlaced&gt;
{
    public async Task HandleAsync(OrderPlaced e, CancellationToken ct)
    {
        if (await db.Invoices.AnyAsync(i =&gt; i.OrderId == e.OrderId, ct)) return; // idempotent
        db.Invoices.Add(new Invoice { OrderId = e.OrderId, Amount = e.Total });
        await db.SaveChangesAsync(ct);
    }
}
public static class BillingModule
{
    public static IServiceCollection AddBillingModule(this IServiceCollection s, string cs)
    {
        s.AddDbContext&lt;BillingDbContext&gt;(o =&gt;
            o.UseSqlServer(cs, sql =&gt; sql.MigrationsHistoryTable("__EFMigrationsHistory", "billing")));
        s.AddScoped&lt;IEventHandler&lt;OrderPlaced&gt;, CreateInvoiceOnOrderPlaced&gt;();
        return s;
    }
}</code></pre>
<p>Wire it up in <code>Host/Program.cs</code>. Create migrations for each DbContext before running.</p>
<pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped&lt;InProcessEventBus&gt;();
builder.Services
    .AddOrdersModule(builder.Configuration.GetConnectionString("Orders")!)
    .AddBillingModule(builder.Configuration.GetConnectionString("Billing")!);

var app = builder.Build();
app.MapGet("/orders/{id:guid}", async (Guid id, IOrdersApi api, CancellationToken ct) =&gt;
    await api.GetOrderAsync(id, ct) is { } o ? Results.Ok(o) : Results.NotFound());
app.MapPost("/orders", async (PlaceOrderRequest req, IOrdersApi api, CancellationToken ct) =&gt;
    Results.Ok(await api.PlaceOrderAsync(req.Total, ct)));
app.Run();

record PlaceOrderRequest(decimal Total);</code></pre>
<h2>Where boundaries really leak</h2>
<p>Billing&#8217;s types are internal and in another project, so the compiler already blocks direct access to them. The real leaks are raw SQL against another schema, a shared DbContext, and a stray project reference or <code>InternalsVisibleTo</code>.</p>
<p>Block the SQL leak in the database. Give each module its own login, use one connection string per module, and run migrations with a separate admin account:</p>
<pre><code class="language-sql">CREATE LOGIN orders_app WITH PASSWORD = '&lt;secret&gt;';
CREATE USER orders_app FOR LOGIN orders_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::orders TO orders_app;</code></pre>
<p>Catch the reference leak in CI. The test project must reference every module assembly:</p>
<pre><code class="language-csharp">public class ModuleBoundaryTests
{
    static readonly Assembly[] Modules =
        [typeof(OrdersModule).Assembly, typeof(BillingModule).Assembly];

    [Fact]
    public void Module_assemblies_were_found()
    {
        // Guards against a vacuous pass if a module is renamed
        Assert.Equal(["Billing", "Orders"], Modules.Select(m =&gt; m.GetName().Name!).Order());
    }

    [Fact]
    public void Modules_do_not_reference_each_other()
    {
        var names = Modules.Select(m =&gt; m.GetName().Name!).ToHashSet();
        foreach (var module in Modules)
        {
            var leaked = module.GetReferencedAssemblies()
                .Select(a =&gt; a.Name!).Where(n =&gt; names.Contains(n) &amp;&amp; n != module.GetName().Name);
            Assert.Empty(leaked);
        }
    }
}</code></pre>
<p>This covers every module pair and passes only if they depend on each other&#8217;s <code>.Contracts</code> assemblies alone. Add a module to the array when you create it.</p>
<h2>When to extract a service</h2>
<p>Valid reasons are concrete: independent scaling, separate teams, different deployment cadences, a different tech stack, compliance isolation, or fault isolation with circuit breakers. Trends and résumé-driven design are not on the list. Before extracting, check that:</p>
<ul>
<li>the boundary has stayed stable for months,</li>
<li>only this module owns its data, with no cross-module joins,</li>
<li>you have measured the bottleneck,</li>
<li>you have capacity for tracing, auth, deployment and on-call.</li>
</ul>
<p>Then pick the cleanest module, put an HTTP client behind the existing interface, move its schema to its own database, and keep a rollback path.</p>
<pre><code class="language-csharp">internal sealed class HttpOrdersApi(HttpClient http) : IOrdersApi
{
    public async Task&lt;OrderDto?&gt; GetOrderAsync(Guid id, CancellationToken ct)
    {
        using var response = await http.GetAsync($"orders/{id}", ct);
        if (response.StatusCode == HttpStatusCode.NotFound) return null;
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync&lt;OrderDto&gt;(ct);
    }

    public async Task&lt;Guid&gt; PlaceOrderAsync(decimal total, CancellationToken ct)
    {
        using var response = await http.PostAsJsonAsync("orders", new { total }, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync&lt;Guid&gt;(ct);
    }
}

public static class OrdersClientExtensions
{
    public static IServiceCollection AddOrdersHttpClient(this IServiceCollection s, Uri baseAddress)
    {
        s.AddHttpClient&lt;IOrdersApi, HttpOrdersApi&gt;(c =&gt; c.BaseAddress = baseAddress)
         .AddStandardResilienceHandler(o =&gt; o.Retry.DisableForUnsafeHttpMethods());
        return s;
    }
}</code></pre>
<p>The standard handler adds timeouts, retries and a circuit breaker. Disabling retries for POST avoids duplicate orders. Decide how callers see an open circuit or a 5xx.</p>
<p>&nbsp;</p>
<p>The outbox dispatcher then has to publish to a broker instead of the in-process bus. Hosting prices and limits change often, so check the provider&#8217;s current documentation.</p>
<p>&nbsp;</p>
<h2>Key takeaways</h2>
<ul>
<li>Start with one deployable unit and optimize for learning speed.</li>
<li>Modularity matters more than the deployment model. Enforce it with contracts, per-schema DB permissions and tests.</li>
<li>Cross-module consistency is not free. Use an outbox and idempotent handlers.</li>
<li>Extract only for reasons you can name or measure.</li>
</ul>
<h2>FAQ</h2>
<h3>Can I use a single database and still call it modular?</h3>
<p>Yes, if each module owns its schema and nothing else touches it. Enforce that with per-module database permissions, not just convention.</p>
<p>&nbsp;</p>
<h3>Is a modular monolith overkill for a two-person team?</h3>
<p>&nbsp;</p>
<p>This is judgement, not evidence. While you are still validating the idea, a plain monolith is fine. Contracts projects and one schema per module cost little, though, and make a later split easier.</p>
<p>&nbsp;</p>
<h2>Sources and further reading</h2>
<ul>
<li><a href="https://www.goodreads.com/author/show/25215.Martin_Fowler/blog?page=34" target="_blank" rel="noopener">Martin Fowler&#8217;s Blog, page 34 (includes the MonolithFirst bliki entry, June 3, 2015)</a> (Goodreads (mirror of Martin Fowler&#8217;s blog))</li>
<li><a href="https://sdtimes.com/continuous-integration/martin-fowler-monolithic-apps-first-microservices-later/" target="_blank" rel="noopener">Martin Fowler: Monolithic apps first, microservices later &#8211; SD Times</a> (SD Times)</li>
<li><a href="https://academy.threedots.tech/knowledge/monolith-first/" target="_blank" rel="noopener">Monolith First</a> (Three Dots Labs Academy)</li>
<li><a href="https://technijian.com/software-development/microservices-vs-monolith-for-startups-the-honest-2026-decision-guide/" target="_blank" rel="noopener">Microservices vs Monolith for Startups: 2026 Architecture Guide</a> (Technijian)</li>
<li><a href="https://distantjob.com/blog/monolith-vs-microservices/" target="_blank" rel="noopener">Monolith vs Microservices in 2026: The Decision Framework Your Engineering Team Needs</a> (DistantJob)</li>
<li><a href="https://martinfowler.com/tags/microservices.html" target="_blank" rel="noopener">tagged by: microservices</a> (martinfowler.com)</li>
</ul>
<p class="image-credits"><em>Images: Image by <a href="https://pixabay.com/users/Innovalabs-22783312/" target="_blank" rel="noopener nofollow">Innovalabs</a> from <a href="https://pixabay.com/photos/software-developer-web-developer-6521720/" target="_blank" rel="noopener nofollow">Pixabay</a>; Image by <a href="https://pixabay.com/users/This_is_Engineering-11384528/" target="_blank" rel="noopener nofollow">This_is_Engineering</a> from <a href="https://pixabay.com/photos/engineer-engineering-4925116/" target="_blank" rel="noopener nofollow">Pixabay</a>.</em></p>


<p class="wp-block-paragraph"></p><p>The post <a href="https://thecodebuzz.com/monolith-first-startups-skip-microservices/">Monolith First: Why Startups Should Skip Microservices</a> first appeared on <a href="https://thecodebuzz.com">TheCodeBuzz</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://thecodebuzz.com/monolith-first-startups-skip-microservices/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
