<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kausar Raza</title>
    <description>The latest articles on DEV Community by Kausar Raza (@kausarraza).</description>
    <link>https://dev.to/kausarraza</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4182821%2Fab780158-8181-49a2-8444-8faa64cc463f.webp</url>
      <title>DEV Community: Kausar Raza</title>
      <link>https://dev.to/kausarraza</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kausarraza"/>
    <language>en</language>
    <item>
      <title>How We Fixed Unused JavaScript and LCP Render Delay in Next.js</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Mon, 02 Nov 2026 18:25:35 +0000</pubDate>
      <link>https://dev.to/kausarraza/how-we-fixed-unused-javascript-and-lcp-render-delay-in-nextjs-1o1k</link>
      <guid>https://dev.to/kausarraza/how-we-fixed-unused-javascript-and-lcp-render-delay-in-nextjs-1o1k</guid>
      <description>&lt;p&gt;We build fast sites for clients, so a mediocre PageSpeed score on our own home page is not a good look. This is the report we started from on 19 October 2026, each issue in it, and exactly what we changed - including two fixes that sounded right and made things worse. The site is Next.js 16 behind Cloudflare; most of this applies to any React site, and the last sections cover what changes for an online shop and where security headers fit in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;Lighthouse 12.8, mobile profile, against the live home page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Performance score&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First Contentful Paint&lt;/td&gt;
&lt;td&gt;1.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest Contentful Paint&lt;/td&gt;
&lt;td&gt;4.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total Blocking Time&lt;/td&gt;
&lt;td&gt;370 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP element&lt;/td&gt;
&lt;td&gt;the header logo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unused JavaScript&lt;/td&gt;
&lt;td&gt;an 89 KiB chunk, gtag.js 65 KiB, AdSense 35 KiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Render-blocking CSS&lt;/td&gt;
&lt;td&gt;452 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM size&lt;/td&gt;
&lt;td&gt;2,239 elements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The LCP breakdown was the giveaway: TTFB 653 ms, resource load delay 822 ms, load time 66 ms, and &lt;strong&gt;render delay 2,918 ms&lt;/strong&gt;. The logo downloaded in a fraction of a second and then waited almost three seconds to be painted. That is not a network problem, and no CDN setting fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unused JavaScript: an 89 KiB chunk nobody asked for
&lt;/h2&gt;

&lt;p&gt;The biggest unused file had a hashed name, so the first job was finding out what was in it. Next.js 16 has a bundle analyser built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npx next experimental-analyze -o
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It writes per-route data to &lt;code&gt;.next/diagnostics/analyze&lt;/code&gt;. The chunk turned out to contain the &lt;code&gt;docx&lt;/code&gt; library and PDF helpers - code for our PDF-to-Word and Word-to-PDF tools - and the home page was not even loading it directly. Next.js &lt;strong&gt;prefetches&lt;/strong&gt; the code for routes linked from the page, and the header links to several tools. The tool page imported a registry of all 54 tools, so prefetching any one tool pulled in the code for all of them. Every tool page was shipping about 1 MB of JavaScript for the same reason.&lt;/p&gt;

&lt;p&gt;The fix was to load each tool's component on its own with &lt;code&gt;next/dynamic&lt;/code&gt;, from a small client module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"use client";
import dynamic from "next/dynamic";

const tools = {
  "word-counter": dynamic(() =&amp;gt; import("./impl/text-tools").then((m) =&amp;gt; m.WordCounter)),
  "pdf-to-word":  dynamic(() =&amp;gt; import("./impl/pdf-tools").then((m) =&amp;gt; m.PdfToWord)),
  // ...one line per tool
};

export function ToolComponent({ slug }) {
  const Tool = tools[slug];
  return Tool ? &amp;lt;Tool /&amp;gt; : null;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dynamic()&lt;/code&gt; still renders the component on the server, so the HTML and its SEO value are unchanged; the browser just stops downloading the other 53. One trap: the server page cannot call a function exported from a &lt;code&gt;"use client"&lt;/code&gt; file - the build fails with &lt;em&gt;"Attempted to call hasTool() from the server but hasTool is on the client"&lt;/em&gt; - so the list of tool slugs lives in a plain shared module. Result: a tool page went from &lt;strong&gt;1,059 KiB to 767 KiB&lt;/strong&gt; of JavaScript (321 to 237 KiB gzipped), and the unused chunk disappeared from the home page report.&lt;/p&gt;

&lt;p&gt;If you see "reduce unused JavaScript" on a Next.js site, look for exactly this pattern: one file that imports everything, used by a route that only needs one piece of it. Icon libraries are the other usual suspect; &lt;code&gt;experimental.optimizePackageImports: ["lucide-react"]&lt;/code&gt; makes sure only the icons you use are bundled.&lt;/p&gt;

&lt;h2&gt;
  
  
  LCP render delay: the fade-in that hid the headline
&lt;/h2&gt;

&lt;p&gt;Why was the &lt;em&gt;logo&lt;/em&gt; the LCP element, not the big headline? Because the hero animated in with a fade starting from &lt;code&gt;opacity: 0&lt;/code&gt;. Chrome does not count an invisible element as a Largest Contentful Paint candidate, so while the headline was fading in, the largest visible thing was the logo - and the measurement only settled once the animation had finished. A 620 ms fade with staggered delays was adding seconds of "render delay" on a throttled phone.&lt;/p&gt;

&lt;p&gt;We kept the entrance and dropped the fade: the hero now slides up 10 px without changing opacity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@keyframes rise-up {
  from { transform: translateY(10px); }
  to   { transform: none; }
}
.rise { animation: rise-up 620ms cubic-bezier(0.22, 1, 0.36, 1) both; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The headline is now a candidate from its first paint. If your LCP element is something small like a logo while a large headline sits right next to it, check for an entrance animation first.&lt;/p&gt;

&lt;p&gt;We also sized the logo to what is actually drawn. It was declared as 260×64 but displayed about 44 px tall, so the image optimiser was choosing a 640 px file for phones. Declared at 180×44 with &lt;code&gt;fetchPriority="high"&lt;/code&gt;, it downloads the right file first. (On this Next.js version &lt;code&gt;priority&lt;/code&gt; alone was not adding the &lt;code&gt;fetchpriority&lt;/code&gt; attribute - check the rendered HTML rather than trusting the prop.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The render delay that remains
&lt;/h2&gt;

&lt;p&gt;Not all of it went away, and it is worth explaining why. In a trace of the page loading in headless Chrome, the main thread was idle for about a second before the first paint. That second was spent on &lt;strong&gt;GPU rasterisation&lt;/strong&gt; - drawing the hero's illustration, gradients and shadows. Removing the illustration as a test brought first paint from about 1.65 s to 1.15-1.45 s. PageSpeed Insights also runs in a headless browser with software rendering, so it sees this cost; a real phone with a GPU sees much less of it. Rebuilding the hero to be cheaper to draw is a design change, not a configuration change, and we have not done it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third-party scripts: wait for the visitor
&lt;/h2&gt;

&lt;p&gt;gtag.js and AdSense were already loading with &lt;code&gt;strategy="lazyOnload"&lt;/code&gt;, which runs them after the page's load event. That sounds late, but it is still inside the window Lighthouse uses to measure Total Blocking Time, and Google Analytics' start-up ran as 300+ ms of long tasks there.&lt;/p&gt;

&lt;p&gt;Both now load on the visitor's first interaction - a scroll, tap, key press or mouse movement. Analytics also has a fallback: four seconds after the page has loaded, once the browser is idle, so a visitor who reads without touching anything is still counted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const EVENTS = ["pointerdown", "keydown", "touchstart", "scroll", "mousemove"];

function afterInteraction(start, idleAfterLoadMs) {
  let done = false;
  const run = () =&amp;gt; {
    if (done) return;
    done = true;
    EVENTS.forEach((e) =&amp;gt; removeEventListener(e, run));
    start();
  };
  EVENTS.forEach((e) =&amp;gt; addEventListener(e, run, { once: true, passive: true }));
  if (idleAfterLoadMs !== undefined)
    addEventListener("load", () =&amp;gt;
      setTimeout(() =&amp;gt; requestIdleCallback(run, { timeout: 2000 }), idleAfterLoadMs));
}

afterInteraction(() =&amp;gt; {
  window.dataLayer = window.dataLayer || [];
  window.gtag = function () { dataLayer.push(arguments); };
  gtag("js", new Date());
  gtag("config", "G-XXXXXXXXXX");
  const s = document.createElement("script");
  s.async = true;
  s.src = "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX";
  document.head.appendChild(s);
}, 4000);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be honest about the trade-offs. A visitor who leaves within about four seconds without interacting is not counted. Ads only load once someone interacts, which costs nothing in practice because our ad slots are below the fold. And we send Core Web Vitals to GA, which report TTFB and FCP before GA has loaded - those are now queued and sent once GA is configured, instead of being silently dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fixes that made things worse
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Inlining the CSS.&lt;/strong&gt; Lighthouse flagged the stylesheet as render-blocking (452 ms), and Next.js has &lt;code&gt;experimental.inlineCss&lt;/code&gt; to put it in the HTML. On this site the stylesheet is 277 KB uncompressed, because one Tailwind file covers the blog, 54 tools and the admin area - and inlining it put the CSS into the page twice, once as a `` tag and again in the React Server Components payload. The home page HTML went from 520 KB to 1.38 MB and First Contentful Paint got &lt;em&gt;worse&lt;/em&gt;. Reverted. Inlining only makes sense when the CSS is small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A modern browserslist.&lt;/strong&gt; Lighthouse reported 13 KiB of legacy JavaScript (polyfills for features every current browser has). Setting &lt;code&gt;browserslist&lt;/code&gt; to Chrome 111, Edge 111, Firefox 111 and Safari 16.4 is the textbook fix. The estimate did not move: those polyfills ship inside the framework's own pre-built code, which your config does not recompile. It does no harm, but it did not fix the warning either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller fixes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image &lt;code&gt;sizes&lt;/code&gt;.&lt;/strong&gt; Portfolio cards are about 400 px wide in a three-column grid but declared &lt;code&gt;33vw&lt;/code&gt;, so they were served from a 640 px file. They now declare &lt;code&gt;(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 420px&lt;/code&gt;, and we added 480 to the optimiser's &lt;code&gt;imageSizes&lt;/code&gt; so there is a file between 384 and 640.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DOM size.&lt;/strong&gt; 2,239 elements is mostly the home page being long. We wrapped every below-the-fold section in &lt;code&gt;content-visibility: auto&lt;/code&gt; with a &lt;code&gt;contain-intrinsic-size&lt;/code&gt;, so the browser skips laying them out until they approach the viewport. That does not change the element count Lighthouse reports - it changes how much of it the browser has to process up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare.&lt;/strong&gt; Email Address Obfuscation injects its own script; we are switching it off. The full list of Cloudflare settings we use is in &lt;a href="https://knowledgemarkg.com/blog/cloudflare-speed-settings-brotli-vs-zstd-vs-gzip" rel="noopener noreferrer"&gt;Cloudflare speed settings and our Brotli vs zstd vs gzip benchmark&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;To compare like with like, both versions were built and tested on the same machine with the same Lighthouse mobile settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before (2 runs)&lt;/th&gt;
&lt;th&gt;After (3 runs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Performance score&lt;/td&gt;
&lt;td&gt;60-62&lt;/td&gt;
&lt;td&gt;66-73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total Blocking Time&lt;/td&gt;
&lt;td&gt;350-400 ms&lt;/td&gt;
&lt;td&gt;210-340 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest Contentful Paint&lt;/td&gt;
&lt;td&gt;7.4-7.9 s&lt;/td&gt;
&lt;td&gt;5.6-5.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript on a tool page&lt;/td&gt;
&lt;td&gt;1,059 KiB&lt;/td&gt;
&lt;td&gt;767 KiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Local numbers are worse than the live site's in absolute terms because the local server fetches its data from a remote API; it is the difference between the columns that counts. With so few runs, treat the ranges as ranges. Lab scores also move from run to run - the field data in Search Console's Core Web Vitals report, from real visitors over 28 days, is what Google ranks on. Our &lt;a href="https://knowledgemarkg.com/blog/core-web-vitals-guide-fix-lcp-inp-cls" rel="noopener noreferrer"&gt;Core Web Vitals guide&lt;/a&gt; explains how to read the two side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes for an e-commerce site
&lt;/h2&gt;

&lt;p&gt;Everything above applies to a shop, with higher stakes: a slow product page loses the sale, not just the reader. The places shops usually lose time are predictable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The main product image is the LCP element.&lt;/strong&gt; Never lazy-load it, give it &lt;code&gt;fetchpriority="high"&lt;/code&gt;, and serve it in AVIF or WebP at the size it is displayed. Lazy-load the gallery thumbnails below it instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party widgets pile up.&lt;/strong&gt; Review widgets, live chat, marketing pixels, heatmaps and recommendation engines each bring their own JavaScript. Load anything not needed for the first view on interaction, as above - a chat widget nobody has opened does not need to block the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Theme and plugin JavaScript.&lt;/strong&gt; On Shopify and WooCommerce, "reduce unused JavaScript" usually points at apps and plugins loading their scripts on every page, including ones that do not use them. Remove what you no longer use; it is the most effective fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTFB on category and search pages.&lt;/strong&gt; Faceted filters and sorting often make these the slowest pages on the site and the hardest to cache. Test them, not just the home page - our &lt;a href="https://knowledgemarkg.com/tools/website-speed-test" rel="noopener noreferrer"&gt;website speed test&lt;/a&gt; can run across every page in your sitemap and list the slowest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching.&lt;/strong&gt; Cache product and category HTML at the edge, but never cart, checkout or account pages. The Cloudflare rules for that are in the post linked above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not defer payment scripts.&lt;/strong&gt; Whatever you delay, leave the checkout's payment provider alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security headers: free, and no speed cost
&lt;/h2&gt;

&lt;p&gt;While auditing performance it is worth checking security headers, because they cost nothing in speed: they are a few hundred bytes on each response. Our free &lt;a href="https://knowledgemarkg.com/tools/security-headers-checker" rel="noopener noreferrer"&gt;security headers checker&lt;/a&gt; fetches a page and grades each header with what to add. This site sends:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`text&lt;br&gt;
Strict-Transport-Security: max-age=31536000&lt;br&gt;
X-Content-Type-Options: nosniff&lt;br&gt;
X-Frame-Options: SAMEORIGIN&lt;br&gt;
Referrer-Policy: strict-origin-when-cross-origin&lt;br&gt;
Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=(), payment=(), browsing-topics=()&lt;br&gt;
Content-Security-Policy: frame-ancestors 'self'; base-uri 'self'; object-src 'none';&lt;br&gt;
                         form-action 'self'; upgrade-insecure-requests&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two deliberate gaps. HSTS has no &lt;code&gt;includeSubDomains&lt;/code&gt; until every subdomain is confirmed to work over HTTPS, because a browser that has seen it will refuse plain HTTP on all of them for a year. And the Content-Security-Policy has no &lt;code&gt;script-src&lt;/code&gt;: a strict script policy in Next.js needs a fresh nonce on every request, which makes every page dynamic and throws away the edge caching our TTFB depends on. That is a real trade-off between security and speed, and for a site with no logins or payments we chose speed. A shop taking payments should weigh it the other way, and a payment page in particular deserves a strict policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I reduce unused JavaScript in Next.js?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Find what is in the unused chunk first - npx next experimental-analyze writes a per-route breakdown. The usual cause is one module importing many components that a route only uses one of; load those with next/dynamic. Also use optimizePackageImports for icon libraries, and load analytics and ads after the first interaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What causes LCP render delay?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The LCP resource has arrived but is not painted yet. Common causes are an entrance animation starting at opacity 0 (Chrome does not count invisible elements), render-blocking CSS or JavaScript, long main-thread tasks, and an expensive first frame to rasterise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my logo the LCP element?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Often because the larger element - usually the headline - is invisible when the page first paints, for example fading in from opacity 0. Make the entrance animation move without fading and the headline becomes the LCP candidate from its first paint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does loading Google Analytics on interaction lose data?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A little. Visitors who leave within a few seconds without scrolling, tapping or moving the mouse are not counted unless you add an idle-time fallback. We load GA on first interaction or four seconds after load, whichever comes first, and queue Web Vitals so they are not dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use experimental.inlineCss in Next.js?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only if your CSS is small. With a 277 KB stylesheet it put the CSS into the HTML twice (a style tag and the RSC payload), the home page HTML grew from 520 KB to 1.38 MB, and First Contentful Paint got worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I speed up an e-commerce website?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Load the main product image first in AVIF or WebP at its displayed size, remove unused apps and plugins, delay chat, review and marketing widgets until interaction, test category and search pages for slow TTFB, and cache product pages at the edge while never caching cart, checkout or account pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do security headers slow down a website?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No - they are a few hundred bytes per response. The only speed trade-off is a strict Content-Security-Policy with per-request nonces, which stops pages being statically cached. The other headers are free.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/reduce-unused-javascript-nextjs-lcp-render-delay" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Crawled vs Discovered - Currently Not Indexed: What 63 Unindexed Pages Taught Me</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Mon, 02 Nov 2026 18:07:36 +0000</pubDate>
      <link>https://dev.to/kausarraza/crawled-vs-discovered-currently-not-indexed-what-63-unindexed-pages-taught-me-2dha</link>
      <guid>https://dev.to/kausarraza/crawled-vs-discovered-currently-not-indexed-what-63-unindexed-pages-taught-me-2dha</guid>
      <description>&lt;p&gt;Search Console's Pages report puts "Crawled - currently not indexed" and "Discovered - currently not indexed" next to each other, and they sound almost the same. They aren't. One means Google has never fetched the page. The other means it fetched the page and decided not to keep it. The fix for one does little for the other.&lt;/p&gt;

&lt;p&gt;On 1 November I checked every URL in this site's sitemap through the URL Inspection API, then asked Google to index 12 of the pages that were missing. Here is what the numbers looked like, what each status meant for my pages, and what changed one day later.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; "Discovered" means Google knows the URL but hasn't crawled it yet. Fix it with internal links from pages that are already indexed, and give it time. "Crawled" means Google read the page and passed on it. Fix it by making the page worth indexing: improve it, merge it into a stronger page, or remove it. Request indexing helped 6 of my 12 pages within a day, but none of the 5 service pages I sent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What the two statuses mean
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discovered - currently not indexed.&lt;/strong&gt; Google found the URL in a sitemap or a link, but hasn't fetched it. Google's help page says it usually happens because crawling the site would have overloaded it, so the crawl was postponed. On a small site, it more often means Google doesn't think the URL is important enough to crawl yet. There's no last crawl date, because there was no crawl.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crawled - currently not indexed.&lt;/strong&gt; Google fetched the page, read it, and left it out of the index. There's a last crawl date. Google says the page may be indexed later and that you don't need to resubmit it. In practice it's a quality judgement. The page didn't add enough that Google's index doesn't already have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL is unknown to Google.&lt;/strong&gt; This one appears in URL Inspection, not the Pages report. Google has no record of the URL at all, not even from the sitemap.&lt;/p&gt;

&lt;h2&gt;
  
  
  My numbers: 63 of 219 URLs not indexed
&lt;/h2&gt;

&lt;p&gt;I ran the URL Inspection API over all 219 URLs in the sitemap on 1 November 2026. 156 were indexed. The other 63 broke down like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;URLs&lt;/th&gt;
&lt;th&gt;Indexed&lt;/th&gt;
&lt;th&gt;Discovered&lt;/th&gt;
&lt;th&gt;Crawled&lt;/th&gt;
&lt;th&gt;Unknown&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blog posts&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free tools&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag pages&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service pages&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything else&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One tag page was excluded by its own noindex tag, which is deliberate. The two problems look very different once you split them by section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Crawled - currently not indexed" looked like
&lt;/h2&gt;

&lt;p&gt;Nine blog posts were crawled and rejected. Seven of them have something in common: they're broad, generic guides from 2025. A Git tutorial, a Python tutorial for beginners, a React hooks guide, a full-stack roadmap, a list of backend project ideas, an AI tools roundup, and a piece on AI and junior developers. Six of those seven were crawled on the &lt;strong&gt;same day, 19 August&lt;/strong&gt;. Thousands of sites cover each of those topics already.&lt;/p&gt;

&lt;p&gt;Meanwhile the posts that are indexed are mostly narrow and first-hand: a specific error message, the exact cause, what I tested. My guess, and it's only a guess from this pattern, is that Google read the generic guides and found nothing it didn't already have.&lt;/p&gt;

&lt;p&gt;That's why resubmitting a crawled page rarely helps on its own. Google already has the page and already decided. What changes the outcome is changing the page. The day before this check I had unpublished 38 off-topic posts from the site's earlier life (computer tips, YouTube guides), because none had a single click in three months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Discovered - currently not indexed" looked like
&lt;/h2&gt;

&lt;p&gt;The Discovered pages were a different crowd: tag pages, the image converter pages (&lt;code&gt;/tools/image-converter/jpg-to-png&lt;/code&gt; and so on), tool category pages and service pages. Many had few or no links pointing at them.&lt;/p&gt;

&lt;p&gt;The converter pages are the clearest case. An Ahrefs crawl the same day listed them as orphan pages, meaning nothing on the site linked to them. They were in the sitemap and nowhere else. I added a block of links to them from the main image converter page and deployed it that evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  I requested indexing for 12 pages. Here is what happened
&lt;/h2&gt;

&lt;p&gt;On 1 November I used Request indexing in Search Console for 12 of the missing URLs, picking the pages that matter most: 5 service pages, 4 blog posts, the portfolio and two tool category pages. I sent about 15 requests in total (a few were repeats) and never saw a "Quota exceeded" message. One day later I inspected the same 12 through the API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;One day later&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blog: PHP2Shell explained&lt;/td&gt;
&lt;td&gt;Discovered&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog: ChunkLoadError after deploy&lt;/td&gt;
&lt;td&gt;Discovered&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog: AI reverse engineering&lt;/td&gt;
&lt;td&gt;Crawled&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog: Core Web Vitals guide&lt;/td&gt;
&lt;td&gt;Crawled&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/portfolio&lt;/td&gt;
&lt;td&gt;Crawled&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/tools/image-tools&lt;/td&gt;
&lt;td&gt;Discovered&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/tools/pdf-tools&lt;/td&gt;
&lt;td&gt;Discovered&lt;/td&gt;
&lt;td&gt;Discovered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 service pages&lt;/td&gt;
&lt;td&gt;Discovered&lt;/td&gt;
&lt;td&gt;Discovered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 service page&lt;/td&gt;
&lt;td&gt;Discovered&lt;/td&gt;
&lt;td&gt;Unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 service pages&lt;/td&gt;
&lt;td&gt;Unknown&lt;/td&gt;
&lt;td&gt;Unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six of twelve indexed in a day. Two of those had been "Crawled - currently not indexed", so a request can move a crawled page too, at least when the page is decent. But none of the five service pages was indexed. The two URLs Google had never heard of were still unknown a day after a direct request, and one page went from Discovered to unknown, which I can't explain.&lt;/p&gt;

&lt;p&gt;Twelve pages over one day is a small sample, so read this as one site's result, not a rule. What it does show is that Request indexing gets a URL into a queue. It doesn't make Google want the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check all your URLs at once
&lt;/h2&gt;

&lt;p&gt;The Pages report shows examples, not every URL, and it lags. The URL Inspection API gives the same answer as the URL Inspection tool for one URL per call, and you can script it. The quota is 2,000 inspections a day per property, which covers a small site in one run. It can't request indexing. There is no API for that outside job postings and livestreams.&lt;/p&gt;

&lt;p&gt;Create a service account in Google Cloud, enable the Search Console API, and add the service account's email as a user on the property in Search Console. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.oauth2&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;service_account&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;googleapiclient.discovery&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;

&lt;span class="n"&gt;creds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;service_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_service_account_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service-account.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.googleapis.com/auth/webmasters.readonly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;gsc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;searchconsole&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gsc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlInspection&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inspectionUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;siteUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# or "sc-domain:example.com"
&lt;/span&gt;    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inspectionResult&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indexStatusResult&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coverageState&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lastCrawlTime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/blog/some-post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop that over the URLs in your sitemap and count the &lt;code&gt;coverageState&lt;/code&gt; values. I called the same endpoint with plain HTTP rather than this library, but the request and response are the same. Keep the service account key out of your repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about each status
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Discovered - currently not indexed
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Link to the page from pages that are already indexed.&lt;/strong&gt; A URL that only exists in a sitemap is a weak signal. Check for orphan pages with a crawler first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the sitemap clean.&lt;/strong&gt; Every URL in it should return 200, be canonical and be indexable. My sitemap still listed a noindex tag page, which is a mixed signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request indexing for the few pages that matter.&lt;/strong&gt; Not for hundreds of URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the server.&lt;/strong&gt; If Google says it postponed crawling because of load, slow responses are part of the problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Crawled - currently not indexed
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask whether the page deserves to be indexed.&lt;/strong&gt; If it repeats what a hundred other pages say, improve it with something only you can add: your data, your test, your error message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge thin pages&lt;/strong&gt; into one stronger page and redirect the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unpublish what you wouldn't miss.&lt;/strong&gt; Fewer weak pages also means less of your site Google has to judge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then request indexing once&lt;/strong&gt;, after the page has actually changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing that doesn't help Google here is IndexNow. Bing, Yandex and a few other engines use it, but Google doesn't. I wrote about that in &lt;a href="https://knowledgemarkg.com/blog/indexnow-202-status-codes-and-whether-it-works" rel="noopener noreferrer"&gt;the IndexNow status codes post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'll re-inspect the service pages and the rest of the 63 in a week and update this post with what moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between Crawled and Discovered - currently not indexed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Discovered means Google knows the URL but has not fetched it yet, so there is no crawl date. Crawled means Google fetched and read the page, then decided not to index it. Discovered is usually about priority and links; Crawled is usually about the page itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I fix Crawled - currently not indexed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Change the page before asking again. Add something other pages do not have, such as your own data or a tested fix, merge thin pages into a stronger one, or unpublish pages you would not miss. Then request indexing once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I fix Discovered - currently not indexed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Link to the page from pages that are already indexed, keep only indexable 200 URLs in your sitemap, and request indexing for the few pages that matter most. Orphan pages that exist only in the sitemap are the most common case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Request indexing in Search Console actually work?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sometimes. On this site, 6 of 12 requested pages were indexed one day later, including two that had been Crawled - currently not indexed. All five service pages stayed unindexed. It puts the URL in a queue but does not guarantee indexing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I request indexing through an API?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not for normal pages. Google's Indexing API only accepts job posting and livestream pages. The URL Inspection API can check a URL's index status, up to 2,000 calls a day per property, but it cannot request indexing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does Discovered - currently not indexed last?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google gives no fixed time. Some of this site's pages moved within a day of a request; others stayed Discovered. Internal links from indexed pages are the most reliable way to raise a URL's priority.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should tag pages be indexed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually only when they hold several posts and add something, like a short introduction. Most of this site's tag pages were Discovered or unknown. A tag with a single post is noindexed, and it should not be in the sitemap either.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/crawled-vs-discovered-currently-not-indexed" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>google</category>
      <category>beginners</category>
    </item>
    <item>
      <title>ASP.NET Core 'Error Unprotecting the Session Cookie': Why Your Keys Disappear on Deploy</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:45:25 +0000</pubDate>
      <link>https://dev.to/kausarraza/aspnet-core-error-unprotecting-the-session-cookie-why-your-keys-disappear-on-deploy-42fe</link>
      <guid>https://dev.to/kausarraza/aspnet-core-error-unprotecting-the-session-cookie-why-your-keys-disappear-on-deploy-42fe</guid>
      <description>&lt;p&gt;You deploy an ASP.NET Core app, or IIS recycles its app pool overnight, and the log fills up with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warn: Microsoft.AspNetCore.Session.SessionMiddleware[7]
      Error unprotecting the session cookie.
      System.Security.Cryptography.CryptographicException: The key {83f2e736-3392-4263-9f0f-ad943c51fe3e} was not found in the key ring.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the same time, users report being logged out, and forms fail with "The antiforgery token could not be decrypted". The session warning is just the visible part. The cause is that the app has &lt;strong&gt;lost the keys it used to encrypt its cookies&lt;/strong&gt;. Every cookie issued before that moment is now unreadable.&lt;/p&gt;

&lt;p&gt;This happened on the API behind this site. It runs on IIS under Plesk, where the app pool has no loaded user profile, so ASP.NET Core kept its keys in memory and threw them away on every restart. Below is what I reproduced, the exact messages each cause produces, and the fix. The deploy side of IIS and Plesk 500 errors is in &lt;a href="https://knowledgemarkg.com/blog/deploy-aspnet-core-plesk-500-errors" rel="noopener noreferrer"&gt;the Plesk 500 errors post&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short fix:&lt;/strong&gt; store the Data Protection keys in a folder that survives restarts and deploys, and give the app a fixed name: &lt;code&gt;services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(@"App_Data\keys")).SetApplicationName("MyApp")&lt;/code&gt;. Then make sure your deploy never deletes that folder. Users who already have old cookies will be logged out once more, and after that it stops.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What the error actually means
&lt;/h2&gt;

&lt;p&gt;ASP.NET Core doesn't store session data, login tickets or antiforgery tokens in plain text. It encrypts them with the &lt;strong&gt;Data Protection&lt;/strong&gt; system, which keeps a set of keys called the &lt;em&gt;key ring&lt;/em&gt;. Each encrypted payload carries the ID of the key that made it. When a request comes in, the app looks up that ID in its ring. If it isn't there, you get "The key {...} was not found in the key ring".&lt;/p&gt;

&lt;p&gt;Three components report it, in three different ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session middleware&lt;/strong&gt; logs "Error unprotecting the session cookie" as a warning, then quietly starts a new, empty session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie authentication&lt;/strong&gt; logs "Cookies was not authenticated. Failure message: Unprotect ticket failed", but only at Information level. With the default &lt;code&gt;"Microsoft.AspNetCore": "Warning"&lt;/code&gt; setting in &lt;code&gt;appsettings.json&lt;/code&gt;, you never see it. The user is just signed out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Antiforgery&lt;/strong&gt; throws &lt;code&gt;AntiforgeryValidationException&lt;/code&gt;: "The antiforgery token could not be decrypted", which usually surfaces as a 400 on form posts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why the session warning is the one people search for. It's the only one that shows up in a default log, even though the logouts hurt more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tested
&lt;/h2&gt;

&lt;p&gt;I built a minimal ASP.NET Core 9 app (runtime 9.0.8, Windows 11, Kestrel) with cookie sign-in, session and antiforgery. I signed in with curl, kept the cookie jar, restarted the app with a different key setup each time, and sent the old cookies back. These are the results.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Keys persisted, plain restart: nothing breaks
&lt;/h3&gt;

&lt;p&gt;With keys stored in a folder and the app restarted on the same folder, the user was still signed in and the old antiforgery token still validated. This is the control. One thing did reset: the session value was empty after the restart. That isn't a key problem. &lt;code&gt;AddDistributedMemoryCache()&lt;/code&gt; keeps session data in process memory, so a restart loses it even when the cookie decrypts fine. If you need sessions to survive restarts, use SQL Server or Redis for the session store as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Key folder wiped: "was not found in the key ring"
&lt;/h3&gt;

&lt;p&gt;Pointing the restarted app at an &lt;em&gt;empty&lt;/em&gt; folder is exactly what a deploy does when it deletes the old site folder, including &lt;code&gt;App_Data&lt;/code&gt;. The user came back anonymous, the antiforgery post failed with "The antiforgery token could not be decrypted", and the log showed the error from the top of this article, naming the old key's ID. The app also silently created a new key, logging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No XML encryptor configured. Key {53f0f160-...} may be persisted to storage in unencrypted form.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Same keys, different application name: "The payload was invalid"
&lt;/h3&gt;

&lt;p&gt;Data Protection isolates apps from one another using an application name. If you don't set one, it is derived from the app's content root path. So moving the site to a different folder, or swapping deployment slots, can change it. I kept the same key folder and changed only &lt;code&gt;SetApplicationName&lt;/code&gt; from &lt;code&gt;SiteA&lt;/code&gt; to &lt;code&gt;SiteB&lt;/code&gt;. The keys were found, but nothing decrypted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warn: Microsoft.AspNetCore.Session.SessionMiddleware[7]
      Error unprotecting the session cookie.
      System.Security.Cryptography.CryptographicException: The payload was invalid.
info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[7]
      Cookies was not authenticated. Failure message: Unprotect ticket failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The antiforgery error changed too, to "The required antiforgery cookie ".AspNetCore.Antiforgery.6asUPwDiMTw" is not present". The suffix of that cookie name is derived from the application name, so the app was looking for a cookie with a different name. If you see "payload was invalid" rather than "key not found", look at the application name before the keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Where the keys go by default
&lt;/h3&gt;

&lt;p&gt;With no configuration, a normal Windows user account gets this at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User profile is available. Using 'C:\Users\&amp;lt;you&amp;gt;\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's why it works on your machine. An IIS app pool doesn't have that folder unless "Load User Profile" is on, and on shared hosting such as Plesk you often can't control that setting. When there is no profile and no registry hive set up for the pool, the framework uses an in-memory ring. I couldn't reproduce that fallback outside IIS, but its warning is in the framework itself (&lt;code&gt;Microsoft.AspNetCore.DataProtection.dll&lt;/code&gt;, 9.0.8):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Neither user profile nor HKLM registry available. Using an ephemeral key repository. Protected data will be unavailable when application exits.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that line is in your startup log, every app pool recycle, idle timeout and deploy logs your users out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three settings, all in &lt;code&gt;Program.cs&lt;/code&gt; before &lt;code&gt;builder.Build()&lt;/code&gt;. This is the configuration this site's API uses now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;keysDir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DirectoryInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFullPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"App_Data/keys"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContentRootPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;keysDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dataProtection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDataProtection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PersistKeysToFileSystem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keysDir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// keys survive restarts and recycles&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetApplicationName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MyApp"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// fixed, so moving the folder or slot doesn't change it&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OperatingSystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsWindows&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dataProtection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProtectKeysWithDpapi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;protectToLocalMachine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// encrypt key files at rest&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes on each line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The folder must be outside the release files.&lt;/strong&gt; If your deploy copies the site over &lt;code&gt;App_Data&lt;/code&gt;, or deletes and re-extracts it, the keys go with it. Keep &lt;code&gt;App_Data&lt;/code&gt; out of the delete step, the same way you keep uploads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;ProtectKeysWithDpapi(protectToLocalMachine: true)&lt;/code&gt;, not the per-user default.&lt;/strong&gt; An app pool has no user profile to hold a per-user DPAPI key. Machine scope works without one. The trade-off is that the key files can only be decrypted on the same machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the application name once and never change it.&lt;/strong&gt; Changing it logs every user out, the same as losing the keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the change, the first deploy logs everyone out once. That's expected: the old cookies were encrypted with keys that no longer exist, and there's nothing to recover them with. From then on, restarts and recycles keep the same keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check it worked
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Sign in, then recycle the app pool or restart the app.&lt;/li&gt;
&lt;li&gt;Check that you are still signed in. If you are, the keys survived.&lt;/li&gt;
&lt;li&gt;Check the log. The "User profile is available" line should be gone, and no "Neither user profile nor HKLM registry available" line should appear. Either one would mean the keys went to memory.&lt;/li&gt;
&lt;li&gt;Check that &lt;code&gt;App_Data\keys&lt;/code&gt; contains &lt;code&gt;key-*.xml&lt;/code&gt; files, and that the timestamps don't change on every restart.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does 'The key was not found in the key ring' mean in ASP.NET Core?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ASP.NET Core encrypted a cookie or token with a key that the app no longer has. The key ring is empty or missing that key, so the payload can't be decrypted. The usual cause is keys stored in memory, or a key folder that was wiped during a deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why am I logged out every time I deploy or recycle the app pool?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Data Protection keys are kept in memory, or in a folder that the deploy deletes. The new process has different keys, so cookies from before can't be decrypted. Persist the keys to a folder outside the release files, with PersistKeysToFileSystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does 'The payload was invalid' mean in ASP.NET Core Data Protection?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The app found the key but the cookie still couldn't be decrypted. A common cause is a different application name. Check SetApplicationName, and check that the app isn't running from a different folder or deployment slot with a different default name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between 'key not found' and 'payload was invalid'?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;'Key not found' means the key ring doesn't contain the key that encrypted the cookie, so the keys were lost or moved. 'Payload was invalid' means the key was found, but the app name or key data doesn't match. Keys first, then application name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should ASP.NET Core store Data Protection keys on IIS?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In a folder that the app pool identity can write to, outside the release folder, for example App_Data/keys. Protect the key files with ProtectKeysWithDpapi(protectToLocalMachine: true), since an app pool usually has no user profile for per-user DPAPI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does 'No XML encryptor configured' mean?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Data Protection keys are being saved to disk without encryption. It's a warning, not an error. Fix it with ProtectKeysWithDpapi on Windows, or with a certificate or Key Vault on other hosts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the session warning 'Error unprotecting the session cookie' mean my users are logged out?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not directly. It's the session middleware, which starts an empty session. Login is handled by cookie authentication, which logs a separate message at Information level. Check that too, because the warning alone doesn't show it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/error-unprotecting-session-cookie-aspnet-core" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aspnetcore</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>LinkedIn 'Content Credentials Label Added' (CR Icon): What It Means and How to Remove It</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:48:11 +0000</pubDate>
      <link>https://dev.to/kausarraza/linkedin-content-credentials-label-added-cr-icon-what-it-means-and-how-to-remove-it-5ehn</link>
      <guid>https://dev.to/kausarraza/linkedin-content-credentials-label-added-cr-icon-what-it-means-and-how-to-remove-it-5ehn</guid>
      <description>&lt;p&gt;I uploaded a banner to a LinkedIn post this week and a small notice appeared in the corner of the editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content credentials label added.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A round &lt;strong&gt;cr&lt;/strong&gt; badge sat on the top-left corner of the image, and it stayed there once the post was published. The same image went to my Facebook Page the same day, and Facebook put &lt;strong&gt;"AI content"&lt;/strong&gt; under the Page name - even though the composer's AI label switch was showing &lt;em&gt;off&lt;/em&gt; when I posted.&lt;/p&gt;

&lt;p&gt;The image came out of ChatGPT. Neither platform looked at the picture and decided it looked AI-generated. Both read something hidden inside the file. Below is what that something is, the test that proves it, and what you can and can't do about the label.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short answer:&lt;/strong&gt; "Content credentials label added" means the image carries a signed &lt;strong&gt;C2PA manifest&lt;/strong&gt; - a small block of provenance data that tools like ChatGPT, Adobe Firefly and some cameras embed in the file. LinkedIn shows it as the CR icon; Facebook and Instagram turn it into an "AI content" / "AI info" label. You can't switch the label off on the platform. It goes away only if the file you upload no longer contains the manifest - and for AI-generated images there are good reasons to leave it on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is inside a ChatGPT image
&lt;/h2&gt;

&lt;p&gt;A PNG file is a list of named chunks: &lt;code&gt;IHDR&lt;/code&gt; for the dimensions, &lt;code&gt;IDAT&lt;/code&gt; for the pixels, &lt;code&gt;IEND&lt;/code&gt; to close it. Listing the chunks of my ChatGPT banner (1672 x 941, 1,704,000 bytes) gave one I didn't expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IHDR      13 bytes
caBX  23,654 bytes   &amp;lt;- the Content Credentials
IDAT    (pixels)
IEND       0 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;caBX&lt;/code&gt; is where C2PA stores its manifest in a PNG. 23,654 bytes is about 1.4% of this file. Reading it with the C2PA project's own Python library (&lt;code&gt;c2pa-python&lt;/code&gt; 0.38) gave this, trimmed to the parts that matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claim generator : OpenAI Media Service API
signed by       : OpenAI OpCo, LLC  (OpenAI Media Service), alg Ps256
actions         :
  c2pa.created              softwareAgent: ChatGPT / gpt-image
                            digitalSourceType: trainedAlgorithmicMedia
  c2pa.converted
  c2pa.watermarked.unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines in there do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;trainedAlgorithmicMedia&lt;/code&gt;&lt;/strong&gt; is the IPTC term for "created by a generative AI model". This is the value platforms look for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The signature&lt;/strong&gt; is OpenAI's. The manifest is cryptographically signed, so anyone can check that it wasn't edited afterwards. (My local check reported the signature as structurally valid but the certificate as "untrusted" - only because I hadn't loaded a trust list, not because anything was wrong.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;c2pa.watermarked.unbound&lt;/code&gt;&lt;/strong&gt; is the one most articles miss. In the C2PA specification (2.3 and later) it means an invisible watermark was inserted into the image itself, without creating a soft binding. That watermark is in the pixels, not the metadata.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The test: same image, with and without the manifest
&lt;/h2&gt;

&lt;p&gt;To prove the label comes from the manifest and not from what the image looks like, I made a copy with identical pixels and no manifest. Re-saving through Pillow does exactly that, because Pillow doesn't carry unknown chunks over:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;banner.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;banner-resaved.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The re-saved file had no &lt;code&gt;caBX&lt;/code&gt; chunk, and &lt;code&gt;c2pa-python&lt;/code&gt; reported &lt;code&gt;ManifestNotFound&lt;/code&gt;. Then I uploaded both to the LinkedIn post editor, one after the other in the same session, and discarded the draft afterwards:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;C2PA manifest&lt;/th&gt;
&lt;th&gt;LinkedIn editor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original ChatGPT PNG&lt;/td&gt;
&lt;td&gt;Yes, signed by OpenAI&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;cr&lt;/strong&gt; badge + "Content credentials label added."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same pixels, re-saved with Pillow&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;No badge, no notice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same picture, different result. LinkedIn is reading the file's metadata, and it is not (in this test, at least) detecting the invisible watermark.&lt;/p&gt;

&lt;p&gt;Facebook I could only observe, not A/B test, because its label only appears on a published post. The original PNG got "AI content" with the composer's AI switch set to off. Clicking the label explains that it is based on a signal added by the tool that created the content - that is the manifest. The post's "..." menu has an &lt;strong&gt;AI content&lt;/strong&gt; item, which opens the same explanation. There is no option there to remove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What LinkedIn shows people who click the CR icon
&lt;/h2&gt;

&lt;p&gt;LinkedIn's help centre calls it the C2PA icon. According to that page, clicking it can show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether AI was used to generate or edit the content, as stated by whoever signed it&lt;/li&gt;
&lt;li&gt;the app or device used - a camera model or an AI tool&lt;/li&gt;
&lt;li&gt;who created the image, and which organisation issued and signed the credential&lt;/li&gt;
&lt;li&gt;when it was signed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LinkedIn also says the feature is still rolling out, so not every account sees it, and that not every AI-generated image can be identified. On the published post, the badge is a button labelled "This image has content credentials."&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check whether an image has Content Credentials
&lt;/h2&gt;

&lt;p&gt;Before you post, you can see what a platform will see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Online:&lt;/strong&gt; the Content Authenticity Initiative's Verify tool (contentcredentials.org/verify) reads the manifest in your browser and shows the same fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From code:&lt;/strong&gt; the C2PA project's own library.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pip install c2pa-python
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;c2pa&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;banner.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image/png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manifests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active_manifest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim_generator_info&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assertions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c2pa.actions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;digitalSourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a file without a manifest, &lt;code&gt;c2pa.Reader&lt;/code&gt; raises &lt;code&gt;ManifestNotFound&lt;/code&gt; - which is also the quickest way to confirm a removal worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to remove the CR label - and whether you should
&lt;/h2&gt;

&lt;p&gt;There is no setting on LinkedIn or Facebook to switch a detected label off. The label follows the file, so the only way to avoid it is to upload a file without the manifest. These do that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-saving or converting the image&lt;/strong&gt; in a tool that doesn't keep C2PA data. Pillow does this by default (tested above, for both PNG and WebP). Many image editors and online converters behave the same way; check the result with the reader rather than assuming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taking a screenshot.&lt;/strong&gt; OpenAI's own documentation says screenshots remove the metadata, and that most social platforms strip metadata from uploads anyway - which is why an image without Content Credentials proves nothing either way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things to weigh before you do it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The watermark stays.&lt;/strong&gt; Removing the manifest doesn't remove the invisible watermark the manifest says was added. LinkedIn didn't react to it in my test; that tells you about LinkedIn today, not about every platform or detector tomorrow. Meta said when it launched these labels that it was working on detecting invisible watermarks as well as metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The label is accurate.&lt;/strong&gt; If ChatGPT made the image, "AI content" is true. For a banner, diagram or illustration, nobody is misled and the label costs you very little. The other case is different: a realistic AI image of a person or an event is exactly what these labels exist for, platforms expect it to be disclosed, and stripping the manifest to pass it off as a real photo is the kind of thing their AI-content rules are written against.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The legitimate reason to strip it is the opposite situation: an image you made yourself, where one small AI-assisted step (an upscale, a background fill) produced a manifest that makes the whole thing read as AI-generated. Facebook's own wording separates "entirely AI-generated" from "only modified with AI" for this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist before you post an AI image
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Know where the image came from.&lt;/strong&gt; If a generator made it, assume it carries a manifest and that LinkedIn, Facebook and Instagram will label it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check, don't guess.&lt;/strong&gt; Run it through the Verify tool or the reader above. Some editing tools keep the manifest, some drop it, and some add their own - the only reliable answer is the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide whether the label is fair.&lt;/strong&gt; Fully AI-generated: leave it. Your own photo or design with a small AI-assisted step: removing the manifest is reasonable, and Facebook's own wording draws the same line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disclose in words when it matters.&lt;/strong&gt; For anything realistic - a person, a place, an event - say it is AI-generated in the post itself. Labels can be lost along the way; your caption isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check after converting.&lt;/strong&gt; If you resize or compress the image for a website, confirm what survived. Conversion can drop the manifest without you asking it to.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;For this site I keep two versions of every AI-generated banner. The original PNG, with its manifest, goes to LinkedIn and Facebook, where the label is honest and harmless. The site itself serves a WebP version made with Pillow - mainly because WebP at quality 88 is about 150 KB against 1.7 MB for the PNG, which is what decides how fast the page loads. The manifest disappears in that conversion as a side effect, and I've left it that way because a page's featured image isn't where people look for provenance. If you care about page weight, the same reasoning applies - I wrote up how much image size matters for &lt;a href="https://knowledgemarkg.com/blog/core-web-vitals-guide-fix-lcp-inp-cls" rel="noopener noreferrer"&gt;Largest Contentful Paint&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The banner that started all this was for my article on the &lt;a href="https://knowledgemarkg.com/blog/sqlcmd-scripting-variable-not-defined" rel="noopener noreferrer"&gt;sqlcmd "scripting variable not defined" error&lt;/a&gt; - a reminder that the content of a file and what tools read from it are not always the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does 'Content credentials label added' mean on LinkedIn?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It means the image or video you uploaded carries a signed C2PA manifest - provenance data embedded by the tool that made it, such as ChatGPT, Adobe Firefly or some cameras. LinkedIn reads it and shows a CR (C2PA) icon on the post that viewers can click for details.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the CR icon on LinkedIn posts?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is LinkedIn's Content Credentials icon, which LinkedIn's help centre calls the C2PA icon. Clicking it can show whether AI was used, the app or device used, who created and signed the content, and when it was signed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I remove the CR label from a LinkedIn post?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no setting to switch it off. The label comes from metadata in the file, so it only disappears if you upload a version without the C2PA manifest - for example after re-saving or converting the image in a tool that doesn't keep it, or by using a screenshot. In a test, the same pixels without the manifest got no label.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Facebook say 'AI content' or 'AI info' on my post when I turned the AI label off?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The toggle is for labelling content yourself. Facebook also adds the label automatically when the file contains a signal from the tool that created it, such as C2PA metadata saying it is AI-generated. In a test, a ChatGPT image was labelled 'AI content' with the composer's AI label set to off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do ChatGPT images contain C2PA metadata?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. A ChatGPT image tested in October 2026 had a 23,654-byte caBX chunk holding a C2PA manifest signed by OpenAI, recording that it was created by ChatGPT (gpt-image) as trainedAlgorithmicMedia, plus a c2pa.watermarked.unbound action indicating an invisible watermark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does removing C2PA metadata remove the AI watermark?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The metadata and the watermark are separate. The C2PA manifest is in the file's metadata; the invisible watermark is in the pixels. Re-saving the image removes the first but not the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How can I check if an image has Content Credentials?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Upload it to the Content Authenticity Initiative's Verify tool at contentcredentials.org/verify, or read it in code with the c2pa-python library. A file without a manifest returns ManifestNotFound.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/linkedin-content-credentials-label-cr-icon" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>linkedin</category>
      <category>python</category>
      <category>privacy</category>
    </item>
    <item>
      <title>sqlcmd 'Scripting Variable Not Defined': Why $( Breaks Your Script and How to Fix It</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Tue, 27 Oct 2026 03:12:08 +0000</pubDate>
      <link>https://dev.to/kausarraza/sqlcmd-scripting-variable-not-defined-why-breaks-your-script-and-how-to-fix-it-21cj</link>
      <guid>https://dev.to/kausarraza/sqlcmd-scripting-variable-not-defined-why-breaks-your-script-and-how-to-fix-it-21cj</guid>
      <description>&lt;p&gt;If you run SQL scripts through &lt;code&gt;sqlcmd&lt;/code&gt; - a deploy step, a data fix, a seed script, a pipeline that loads text into a table - sooner or later one of them prints this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'Amount' scripting variable not defined.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name in quotes changes; the cause doesn't. Somewhere in your script there is a &lt;code&gt;$(&lt;/code&gt; followed by a name and a &lt;code&gt;)&lt;/code&gt;, and sqlcmd has read it as one of its own variables. Usually it's inside a string you meant literally: a price template, a shell command stored in a table, a blog post with a code sample in it. I hit it on this site, whose posts are inserted by a script that pipes generated SQL through sqlcmd - any post with a shell example like &lt;code&gt;$(cygpath -w file)&lt;/code&gt; in it tripped over this.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short fix:&lt;/strong&gt; if the script doesn't use sqlcmd variables at all, run it with &lt;code&gt;sqlcmd -x&lt;/code&gt; (or &lt;code&gt;Invoke-Sqlcmd -DisableVariables&lt;/code&gt; in PowerShell). If it does use them, break the pattern inside the SQL text instead: write &lt;code&gt;'$' + '(Amount)'&lt;/code&gt; or &lt;code&gt;CHAR(36) + '(Amount)'&lt;/code&gt;. And if you build the command in Bash or PowerShell, put the query in single quotes - in double quotes the shell eats &lt;code&gt;$(...)&lt;/code&gt; before sqlcmd ever sees it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the fix. What makes this worth an article is that the error message is the &lt;em&gt;least&lt;/em&gt; dangerous thing sqlcmd does with &lt;code&gt;$(...)&lt;/code&gt; - the dangerous cases print nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What sqlcmd is doing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sqlcmd&lt;/code&gt; is not just a pipe to SQL Server. Before it sends each batch, it runs its own pre-processor over the text, and one of that pre-processor's jobs is replacing &lt;code&gt;$(Name)&lt;/code&gt; with the value of a &lt;em&gt;scripting variable&lt;/em&gt; called &lt;code&gt;Name&lt;/code&gt;. This is a feature: it lets one script run against different databases or servers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;setvar&lt;/span&gt; &lt;span class="n"&gt;TargetDb&lt;/span&gt; &lt;span class="nv"&gt;"Shop_Staging"&lt;/span&gt;
&lt;span class="n"&gt;USE&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TargetDb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is that the pre-processor doesn't care about T-SQL quoting. It replaces &lt;code&gt;$(Name)&lt;/code&gt; inside string literals - &lt;code&gt;'...'&lt;/code&gt; and &lt;code&gt;N'...'&lt;/code&gt; - exactly as it does in code. To sqlcmd, &lt;code&gt;'Total: $(Amount)'&lt;/code&gt; is a variable reference wrapped in some characters that happen to be quotes.&lt;/p&gt;

&lt;p&gt;A value for a variable can come from five places. From lowest to highest precedence, according to Microsoft's documentation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;system-level environment variables&lt;/li&gt;
&lt;li&gt;user-level environment variables&lt;/li&gt;
&lt;li&gt;variables set in the shell before starting sqlcmd (&lt;code&gt;SET X=Y&lt;/code&gt;, &lt;code&gt;export X=Y&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlcmd -v X=Y&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;:setvar X Y&lt;/code&gt; inside the script&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only if none of the five has a value do you get "scripting variable not defined". Keep the first three in mind - they are where the silent failures come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tested
&lt;/h2&gt;

&lt;p&gt;Everything below was run on 26 October 2026 with the classic ODBC-based &lt;code&gt;sqlcmd&lt;/code&gt; (version 15.0.1300.359, Windows) against SQL Server 2022, using only &lt;code&gt;SELECT&lt;/code&gt; and &lt;code&gt;PRINT&lt;/code&gt;. The test script is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'Total: $(Amount)'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1. Without -b, the error doesn't stop anything
&lt;/h3&gt;

&lt;p&gt;Run it plainly and you get the message on stderr - and the query &lt;em&gt;still runs&lt;/em&gt;, with the literal text left in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;r
-
Total: $(Amount)

(1 rows affected)
'Amount' scripting variable not defined.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code is &lt;strong&gt;0&lt;/strong&gt;. If this script were an &lt;code&gt;INSERT&lt;/code&gt;, the row would be saved exactly as written, and any CI job checking the exit code would report success. Microsoft's own page contradicts itself here: one sentence says "the execution of the script stops", while a worked example further down shows the row being returned. The example - and my test - are right. It only stops when you pass &lt;code&gt;-b&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. With -b, it stops - halfway through
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;-b&lt;/code&gt;, the batch containing the reference is never sent and sqlcmd exits with code 1. But batches before it have already run. A three-batch script printed &lt;code&gt;batch 1 ran&lt;/code&gt;, then the error, then nothing. If the script isn't wrapped in a transaction, you are left with the first half applied. That's still the behaviour you want in automation - just know that "failed" doesn't mean "nothing happened".&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Environment variables fill it in - silently
&lt;/h3&gt;

&lt;p&gt;This is the one that actually corrupts data. I changed the test string to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'path=$(USERNAME)'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;envleak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$(SQLCMDSERVER)'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No message, exit code 0, and the result came back with my Windows user name in place of &lt;code&gt;$(USERNAME)&lt;/code&gt; and the server's address in place of &lt;code&gt;$(SQLCMDSERVER)&lt;/code&gt;. sqlcmd treats every environment variable as a scripting variable, and it defines a set of its own (&lt;code&gt;SQLCMDSERVER&lt;/code&gt;, &lt;code&gt;SQLCMDUSER&lt;/code&gt;, &lt;code&gt;SQLCMDDBNAME&lt;/code&gt; and more). So a stored string that happens to contain &lt;code&gt;$(PATH)&lt;/code&gt;, &lt;code&gt;$(TEMP)&lt;/code&gt;, &lt;code&gt;$(COMPUTERNAME)&lt;/code&gt; or &lt;code&gt;$(HOME)&lt;/code&gt; is quietly rewritten with values from whatever machine ran the script - including, on a build agent, paths and account names you didn't mean to put in your database. Names are case-insensitive too: &lt;code&gt;-v AMOUNT=5&lt;/code&gt; filled in &lt;code&gt;$(amount)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The visible error only appears when the name matches nothing. You only get a warning when you're lucky.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A broken pattern is a syntax error
&lt;/h3&gt;

&lt;p&gt;An unclosed &lt;code&gt;$(&lt;/code&gt; or a name with a space in it gives a different message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sqlcmd: Error: Syntax error at line 1 in file 't7.sql'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the batch is &lt;em&gt;not&lt;/em&gt; sent - but without &lt;code&gt;-b&lt;/code&gt; the exit code is still 0, so the script "succeeds" having skipped that statement.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Things that turned out to be fine
&lt;/h3&gt;

&lt;p&gt;Some things I expected to break and didn't, in this version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$(Anything)&lt;/code&gt; inside a &lt;code&gt;--&lt;/code&gt; or &lt;code&gt;/* */&lt;/code&gt; comment produced no message.&lt;/li&gt;
&lt;li&gt;A multi-line string literal containing a line that starts with &lt;code&gt;GO&lt;/code&gt;, &lt;code&gt;:setvar&lt;/code&gt; or &lt;code&gt;!!echo&lt;/code&gt; was kept as text. sqlcmd did not split the batch, set a variable or run a shell command.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So it's specifically &lt;code&gt;$(...)&lt;/code&gt; in code and string literals you need to watch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: turn variables off with -x
&lt;/h2&gt;

&lt;p&gt;If the script never uses sqlcmd variables, this is the whole fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlcmd &lt;span class="nt"&gt;-S&lt;/span&gt; myserver &lt;span class="nt"&gt;-d&lt;/span&gt; Shop &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; load_content.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-x&lt;/code&gt; makes sqlcmd ignore scripting variables completely - no substitution, no message, the text goes to SQL Server as written. It's an all-or-nothing switch: in my test, &lt;code&gt;-x&lt;/code&gt; together with &lt;code&gt;-v Amount=19.99&lt;/code&gt; still produced the literal &lt;code&gt;$(Amount)&lt;/code&gt;, and &lt;code&gt;:setvar&lt;/code&gt; inside the file was ignored as well. If the script relies on &lt;code&gt;$(TargetDb)&lt;/code&gt; somewhere, &lt;code&gt;-x&lt;/code&gt; breaks that.&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;-b&lt;/code&gt; alongside it, so real SQL errors still fail the run.&lt;/p&gt;

&lt;h3&gt;
  
  
  PowerShell: Invoke-Sqlcmd -DisableVariables
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;SqlServer&lt;/code&gt; module's &lt;code&gt;Invoke-Sqlcmd&lt;/code&gt; uses the same variable syntax, and its equivalent of &lt;code&gt;-x&lt;/code&gt; is &lt;code&gt;-DisableVariables&lt;/code&gt;. Microsoft's documentation describes it in nearly the same words as &lt;code&gt;-x&lt;/code&gt; - "useful when a script contains many INSERT statements that may contain strings that have the same format as variables". (I didn't have the module installed for this test, so this one is from the docs, not from a run.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Invoke-Sqlcmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ServerInstance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;myserver&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Database&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Shop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-InputFile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\load_content.sql&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-DisableVariables&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix 2: break the pattern inside the SQL
&lt;/h2&gt;

&lt;p&gt;When the script needs variables &lt;em&gt;and&lt;/em&gt; contains literal &lt;code&gt;$(...)&lt;/code&gt; text, change how the text is written so the pre-processor never sees &lt;code&gt;$(&lt;/code&gt;. Both of these came back as the literal &lt;code&gt;Total: $(Amount)&lt;/code&gt; with no message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'Total: $'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'(Amount)'&lt;/span&gt;          &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;concat_fix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="s1"&gt;'Total: '&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'(Amount)'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;char_fix&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CHAR(36)&lt;/code&gt; is the dollar sign. Use &lt;code&gt;NCHAR(36)&lt;/code&gt; and &lt;code&gt;N'...'&lt;/code&gt; for &lt;code&gt;nvarchar&lt;/code&gt; data. There is no escape sequence for &lt;code&gt;$(&lt;/code&gt; in sqlcmd - splitting it is the workaround. If you generate SQL from code, do the split in the generator so nobody has to remember it by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sql_literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# N'...' with quotes doubled, and "$(" split so sqlcmd can't see a variable
&lt;/span&gt;    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"''"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; + N&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;N&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for SQL database projects and SSMS in SQLCMD mode, which use the same &lt;code&gt;$(Name)&lt;/code&gt; syntax for their variables. I haven't tested those here, but the split string has no &lt;code&gt;$(&lt;/code&gt; in it for any of them to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: check what your shell did first
&lt;/h2&gt;

&lt;p&gt;A related trap sits one layer earlier, in the shell. Both Bash and PowerShell use &lt;code&gt;$(...)&lt;/code&gt; for &lt;em&gt;their own&lt;/em&gt; command substitution, and both expand it inside double quotes before sqlcmd starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"SELECT 'Total: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;Amount&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;' AS r"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
bash: Amount: &lt;span class="nb"&gt;command &lt;/span&gt;not found
SELECT &lt;span class="s1"&gt;'Total: '&lt;/span&gt; AS r
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PowerShell does the same and says &lt;code&gt;The term 'Amount' is not recognized as the name of a cmdlet, function, script file, or operable program&lt;/code&gt;, then hands over &lt;code&gt;SELECT 'Total: ' AS r&lt;/code&gt;. The query runs, the value is just gone - and sqlcmd never complains because it never saw a &lt;code&gt;$(&lt;/code&gt;. If what comes out has an empty gap where your text was, this is why. Use single quotes in the shell (doubling the inner SQL quotes in PowerShell), escape it as `&lt;code&gt;$(&lt;/code&gt; in a PowerShell double-quoted string, or - better - put the SQL in a file and pass it with &lt;code&gt;-i&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix to use
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loading content&lt;/strong&gt; (articles, templates, HTML, anything users wrote): &lt;code&gt;-x&lt;/code&gt; or &lt;code&gt;-DisableVariables&lt;/code&gt;. You can't predict what text contains, and environment variable substitution makes it unsafe to rely on the error appearing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment scripts that use variables&lt;/strong&gt;: keep variables on, split any literal &lt;code&gt;$(&lt;/code&gt; with &lt;code&gt;CHAR(36)&lt;/code&gt;, and always pass &lt;code&gt;-b&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large or repeated data loads&lt;/strong&gt;: consider not generating SQL text at all. A parameterised command from your application, &lt;code&gt;bcp&lt;/code&gt; or &lt;code&gt;BULK INSERT&lt;/code&gt; doesn't put the data through sqlcmd's pre-processor, so there's nothing to escape.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The other sqlcmd defaults that cause trouble
&lt;/h2&gt;

&lt;p&gt;This is the second article I've written about sqlcmd quietly behaving differently from the application that normally talks to the same database. The first was &lt;a href="https://knowledgemarkg.com/blog/sql-server-msg-1934-set-options-quoted-identifier" rel="noopener noreferrer"&gt;Msg 1934, "SET options have incorrect settings: QUOTED_IDENTIFIER"&lt;/a&gt; - the classic sqlcmd starts sessions with &lt;code&gt;QUOTED_IDENTIFIER OFF&lt;/code&gt;, which breaks writes to any table with a filtered index. The script that inserts posts on this site now passes both &lt;code&gt;-x&lt;/code&gt; and a &lt;code&gt;SET QUOTED_IDENTIFIER ON&lt;/code&gt; where it needs one. If you run SQL by hand against a database an ORM normally manages, it's also worth reading about &lt;a href="https://knowledgemarkg.com/blog/ef-core-there-is-already-an-object-named-in-the-database" rel="noopener noreferrer"&gt;EF Core's "There is already an object named X in the database"&lt;/a&gt;, which is what happens when hand-run SQL and migration history drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does 'scripting variable not defined' mean in sqlcmd?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your script contains $(Name) and sqlcmd treats that as a reference to a scripting variable called Name. It found no value for it in :setvar, -v or the environment, so it prints the message. The text is usually a literal string you didn't mean as a variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the script stop when sqlcmd says a scripting variable is not defined?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not by default. In a test with sqlcmd 15, the statement still ran with the literal $(Name) text and the exit code was 0. Only with -b does sqlcmd skip that batch and exit with code 1, and batches before it have already run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop sqlcmd from replacing $( in my data?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run sqlcmd with -x, which turns off scripting variables entirely, or use Invoke-Sqlcmd -DisableVariables in PowerShell. If the script also needs variables, write the dollar sign as CHAR(36) or split the string as '$' + '(Name)' so sqlcmd never sees the $( pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there an escape character for $( in sqlcmd?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. sqlcmd has no escape sequence for the $( pattern. The workarounds are -x to disable variables, or building the text so the two characters are never next to each other in the script, for example CHAR(36) + '(Name)'.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can environment variables change my data when I run sqlcmd?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. sqlcmd treats environment variables as scripting variables, so a string containing $(USERNAME), $(PATH) or $(TEMP) is silently replaced with that machine's value, with no warning and exit code 0. That's why -x is the safer choice for loading user-written content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the order of precedence for sqlcmd variables?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;From lowest to highest: system environment variables, user environment variables, variables set in the shell before starting sqlcmd, the -v option, and :setvar inside the script. Variable names are case-insensitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my query empty where $(...) was, with no sqlcmd error?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your shell probably expanded it first. Bash and PowerShell both treat $(...) inside double quotes as command substitution, so the text is removed before sqlcmd starts. Use single quotes, escape it as `$( in PowerShell, or put the SQL in a file and pass it with -i.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/sqlcmd-scripting-variable-not-defined" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>sqlserver</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>SQL Server Msg 1934: 'SET Options Have Incorrect Settings: QUOTED_IDENTIFIER' - Why It Happens and How to Fix It</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:58:22 +0000</pubDate>
      <link>https://dev.to/kausarraza/sql-server-msg-1934-set-options-have-incorrect-settings-quotedidentifier-why-it-happens-and-3490</link>
      <guid>https://dev.to/kausarraza/sql-server-msg-1934-set-options-have-incorrect-settings-quotedidentifier-why-it-happens-and-3490</guid>
      <description>&lt;p&gt;This is the error, exactly as SQL Server printed it to me while I was fixing this site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Msg 1934, Level 16, State 1, Line 1
UPDATE failed because the following SET options have incorrect settings: 'QUOTED_IDENTIFIER'.
Verify that SET options are correct for use with indexed views and/or indexes on computed
columns and/or filtered indexes and/or query notifications and/or XML data type methods
and/or spatial index operations.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I was fixing a broken profile photo on this site by updating one row in the &lt;code&gt;AspNetUsers&lt;/code&gt; table from the command line. The same table is updated every day by the ASP.NET Core application that runs this site, and that application has never once seen this error. Same database, same table, same kind of statement - one client fails, the other doesn't. That difference is the whole explanation, and once you see it the fix takes one line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short fix:&lt;/strong&gt; put &lt;code&gt;SET QUOTED_IDENTIFIER ON;&lt;/code&gt; at the top of your script, before the statement that fails. If you run scripts with the classic &lt;code&gt;sqlcmd&lt;/code&gt;, add the &lt;code&gt;-I&lt;/code&gt; switch instead. If the failing statement is inside a stored procedure, re-create the procedure from a session that has the option on - setting it inside the procedure does nothing. The rest of this article explains why, and how to find every place it can bite.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What the error actually means
&lt;/h2&gt;

&lt;p&gt;Read the long sentence at the end of the message as a checklist. SQL Server is saying: this statement touches one of six features that only work with a particular set of session options - indexed views, indexes on computed columns, filtered indexes, query notifications, XML data type methods, or spatial indexes - and your session has at least one of those options set the wrong way. The option it names in quotes (&lt;code&gt;'QUOTED_IDENTIFIER'&lt;/code&gt; here) is the one that is wrong.&lt;/p&gt;

&lt;p&gt;The first word changes with the statement. You will see &lt;code&gt;INSERT failed&lt;/code&gt;, &lt;code&gt;UPDATE failed&lt;/code&gt;, &lt;code&gt;DELETE failed&lt;/code&gt;, &lt;code&gt;SELECT failed&lt;/code&gt;, &lt;code&gt;CREATE INDEX failed&lt;/code&gt; or &lt;code&gt;ALTER INDEX failed&lt;/code&gt; - it is the same error, number 1934, every time. And the option in quotes is not always &lt;code&gt;QUOTED_IDENTIFIER&lt;/code&gt;; it can also be &lt;code&gt;ANSI_WARNINGS&lt;/code&gt;, &lt;code&gt;ANSI_NULLS&lt;/code&gt;, &lt;code&gt;ARITHABORT&lt;/code&gt; and a few others. The cause is always the same shape: a client connected with a non-default setting.&lt;/p&gt;

&lt;p&gt;In my case the feature involved was a &lt;strong&gt;filtered index&lt;/strong&gt;. ASP.NET Core Identity creates one on &lt;code&gt;AspNetUsers&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;OBJECT_NAME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filter_definition&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;has_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- table         name            filter_definition&lt;/span&gt;
&lt;span class="c1"&gt;-- AspNetRoles   RoleNameIndex   ([NormalizedName] IS NOT NULL)&lt;/span&gt;
&lt;span class="c1"&gt;-- AspNetUsers   UserNameIndex   ([NormalizedUserName] IS NOT NULL)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two rows are the only filtered indexes in this site's database, and both come from Identity: a unique index on a column that allows NULLs gets an &lt;code&gt;IS NOT NULL&lt;/code&gt; filter, so that more than one row can be NULL. So if your project uses ASP.NET Core Identity on SQL Server, you have filtered indexes whether you ever wrote one or not - and any script that updates users or roles from the wrong client will hit this error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one client fails and another doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;QUOTED_IDENTIFIER&lt;/code&gt; is a session setting, and each client library picks its own default when it connects. That is the entire mystery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ADO.NET / SqlClient, Entity Framework Core, the ODBC driver, OLE DB, and SSMS&lt;/strong&gt; connect with it &lt;strong&gt;ON&lt;/strong&gt;. Microsoft's documentation says the ODBC driver and OLE DB provider set it on automatically when connecting. This is why the application never sees the error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The classic &lt;code&gt;sqlcmd&lt;/code&gt;&lt;/strong&gt; (the ODBC-based one that ships with the SQL Server client tools) starts with it &lt;strong&gt;OFF&lt;/strong&gt;. I checked rather than trusting memory: a plain session from &lt;code&gt;sqlcmd&lt;/code&gt; 15.0 reported &lt;code&gt;SESSIONPROPERTY('QUOTED_IDENTIFIER') = 0&lt;/code&gt;. Its &lt;code&gt;-I&lt;/code&gt; switch turns it on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The newer &lt;code&gt;sqlcmd&lt;/code&gt; (Go)&lt;/strong&gt; always has it on. Microsoft's docs say its &lt;code&gt;-I&lt;/code&gt; switch is ignored because quoted identifiers are always enabled. So whether this error happens can depend on which &lt;code&gt;sqlcmd&lt;/code&gt; is first on your PATH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB-Library&lt;/strong&gt; applications default to OFF - old, but still found in legacy tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check what your own session has before guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;SESSIONPROPERTY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'QUOTED_IDENTIFIER'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;quoted_identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;SESSIONPROPERTY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ANSI_NULLS'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ansi_nulls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;SESSIONPROPERTY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ANSI_WARNINGS'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ansi_warnings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;SESSIONPROPERTY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ANSI_PADDING'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ansi_padding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;SESSIONPROPERTY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ARITHABORT'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;arithabort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;SESSIONPROPERTY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CONCAT_NULL_YIELDS_NULL'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;concat_null_yields_null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From &lt;code&gt;sqlcmd&lt;/code&gt; on this machine that returned &lt;code&gt;0&lt;/code&gt; for &lt;code&gt;QUOTED_IDENTIFIER&lt;/code&gt; and for &lt;code&gt;ARITHABORT&lt;/code&gt;, and &lt;code&gt;1&lt;/code&gt; for the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tested
&lt;/h2&gt;

&lt;p&gt;To be sure which statements fail and which don't, I reproduced it on SQL Server 2022 (compatibility level 150) with a temporary copy of the Identity table - same filtered unique index, nothing touching real data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;QUOTED_IDENTIFIER&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="n"&gt;Users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NormalizedUserName&lt;/span&gt; &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;UserNameIndex&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="n"&gt;Users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NormalizedUserName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;NormalizedUserName&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GO&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;QUOTED_IDENTIFIER&lt;/span&gt; &lt;span class="k"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- now behave like a default sqlcmd session&lt;/span&gt;
&lt;span class="k"&gt;GO&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="n"&gt;Users&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;NormalizedUserName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;'B'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- Msg 1934&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statement&lt;/th&gt;
&lt;th&gt;Session settings&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;INSERT, UPDATE, DELETE on the table&lt;/td&gt;
&lt;td&gt;QUOTED_IDENTIFIER OFF&lt;/td&gt;
&lt;td&gt;Msg 1934 (each names its statement)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain SELECT on the table&lt;/td&gt;
&lt;td&gt;QUOTED_IDENTIFIER OFF&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SELECT using an xml &lt;code&gt;.value()&lt;/code&gt; method&lt;/td&gt;
&lt;td&gt;QUOTED_IDENTIFIER OFF&lt;/td&gt;
&lt;td&gt;Msg 1934, "SELECT failed"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CREATE INDEX with a WHERE filter&lt;/td&gt;
&lt;td&gt;QUOTED_IDENTIFIER OFF&lt;/td&gt;
&lt;td&gt;Msg 1934, "CREATE INDEX failed"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALTER INDEX ... REBUILD on the filtered index&lt;/td&gt;
&lt;td&gt;QUOTED_IDENTIFIER OFF&lt;/td&gt;
&lt;td&gt;Msg 1934, "ALTER INDEX failed"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UPDATE&lt;/td&gt;
&lt;td&gt;QUOTED_IDENTIFIER ON, ARITHABORT OFF&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UPDATE&lt;/td&gt;
&lt;td&gt;QUOTED_IDENTIFIER ON, ANSI_WARNINGS OFF&lt;/td&gt;
&lt;td&gt;Msg 1934 naming 'ANSI_WARNINGS'&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EXEC of a procedure created with QUOTED_IDENTIFIER OFF&lt;/td&gt;
&lt;td&gt;caller has it ON&lt;/td&gt;
&lt;td&gt;Msg 1934 inside the procedure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UPDATE from &lt;code&gt;sqlcmd -I&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;QUOTED_IDENTIFIER ON&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of those results are worth pulling out, because they are where people lose time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reads usually still work.&lt;/strong&gt; A plain SELECT on a table with a filtered index did not fail; the index simply can't be used. That is why a script can read everything fine and then fall over at the first write - it looks like a permissions or locking problem when it isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ARITHABORT OFF was not a problem&lt;/strong&gt; on this server, even though the session had it off. With &lt;code&gt;ANSI_WARNINGS&lt;/code&gt; on and the database at compatibility level 90 or higher, SQL Server treats &lt;code&gt;ARITHABORT&lt;/code&gt; as on. If you do see &lt;code&gt;'ARITHABORT'&lt;/code&gt; in the message, look at &lt;code&gt;ANSI_WARNINGS&lt;/code&gt; and the compatibility level first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stored procedures remember their creation setting.&lt;/strong&gt; The caller's setting is irrelevant - more on that below, because it is the case that survives every other fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to fix it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. In a script: set the option before the statement
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;QUOTED_IDENTIFIER&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ANSI_NULLS&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GO&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;AspNetUsers&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;    &lt;span class="n"&gt;ProfileImageUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/uploads/profiles/new-photo.webp'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Microsoft's documentation is precise about when this takes effect: &lt;code&gt;QUOTED_IDENTIFIER&lt;/code&gt; applies at &lt;em&gt;parse&lt;/em&gt; time, and a &lt;code&gt;SET&lt;/code&gt; inside a batch changes parsing from that point on. So it works on the same line as long as it comes first. Putting it in its own batch with &lt;code&gt;GO&lt;/code&gt;, as above, is the version that never surprises anyone.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. From sqlcmd: use -I, or the Go version
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlcmd &lt;span class="nt"&gt;-S&lt;/span&gt; myserver &lt;span class="nt"&gt;-d&lt;/span&gt; mydb &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; fix.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-I&lt;/code&gt; applies only to the classic ODBC &lt;code&gt;sqlcmd&lt;/code&gt;. The newer Go-based &lt;code&gt;sqlcmd&lt;/code&gt; has quoted identifiers on permanently, so the switch does nothing there - and the error doesn't happen there either. If you have a deploy script that runs SQL files through &lt;code&gt;sqlcmd&lt;/code&gt;, add &lt;code&gt;-I&lt;/code&gt; to it now; it costs nothing on the Go version and fixes the ODBC one.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. In a stored procedure: re-create it, don't SET inside it
&lt;/h3&gt;

&lt;p&gt;When a procedure is created, SQL Server stores the &lt;code&gt;QUOTED_IDENTIFIER&lt;/code&gt; and &lt;code&gt;ANSI_NULLS&lt;/code&gt; settings that were in force at that moment, and uses them for every later call. A &lt;code&gt;SET QUOTED_IDENTIFIER ON&lt;/code&gt; written inside the procedure body has no effect. In my test, a procedure created from an OFF session failed with Msg 1934 even when called from a session that had the option on.&lt;/p&gt;

&lt;p&gt;Find every module saved with the wrong setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;OBJECT_SCHEMA_NAME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
       &lt;span class="n"&gt;OBJECT_NAME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
       &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uses_quoted_identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uses_ansi_nulls&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql_modules&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uses_quoted_identifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uses_ansi_nulls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each row, script the module out and run its &lt;code&gt;ALTER&lt;/code&gt; (or &lt;code&gt;CREATE OR ALTER&lt;/code&gt;) from a session with both options on - SSMS, or &lt;code&gt;sqlcmd -I&lt;/code&gt;. The same applies to triggers and functions; they are in &lt;code&gt;sys.sql_modules&lt;/code&gt; too. A deployment tool that runs your migration scripts through the classic &lt;code&gt;sqlcmd&lt;/code&gt; is the usual way these get saved wrong in the first place, so fix the tool as well as the procedures.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. If the message names a different option
&lt;/h3&gt;

&lt;p&gt;The features in the error message need this full set, not only &lt;code&gt;QUOTED_IDENTIFIER&lt;/code&gt;. Setting them together at the top of a maintenance script means you won't meet the next one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ANSI_NULLS&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ANSI_PADDING&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ANSI_WARNINGS&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ARITHABORT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;CONCAT_NULL_YIELDS_NULL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;QUOTED_IDENTIFIER&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;NUMERIC_ROUNDABORT&lt;/span&gt; &lt;span class="k"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What not to do
&lt;/h3&gt;

&lt;p&gt;Don't drop the filtered index to make the error go away. On an Identity table it is the unique constraint that stops two accounts from sharing a username; removing it trades a clear error for silent duplicate users. The same reasoning as any unique index - it is a rule the database enforces for you, which I wrote about in &lt;a href="https://knowledgemarkg.com/blog/database-indexes-and-idempotency" rel="noopener noreferrer"&gt;database indexes and idempotency&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;The reason I was updating Identity rows by hand at all is worth a sentence, because it is the common route into this error: a deploy had deleted the uploads folder on the server, and the fastest repair for one broken image was a direct &lt;code&gt;UPDATE&lt;/code&gt; rather than going through the admin screens. Direct SQL against tables an ORM normally manages is exactly when you leave the client library's defaults behind - the same way EF Core's migration history can surprise you, as in &lt;a href="https://knowledgemarkg.com/blog/ef-core-there-is-already-an-object-named-in-the-database" rel="noopener noreferrer"&gt;"There is already an object named X in the database"&lt;/a&gt;. The application had been setting &lt;code&gt;QUOTED_IDENTIFIER ON&lt;/code&gt; for me all along; the command line wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does 'SET options have incorrect settings: QUOTED_IDENTIFIER' mean?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is SQL Server error 1934. The statement touches a feature that requires QUOTED_IDENTIFIER ON - a filtered index, an indexed view, an index on a computed column, an XML data type method, query notifications or a spatial index - and the current session has it OFF. The word before 'failed' (INSERT, UPDATE, DELETE, SELECT, CREATE INDEX, ALTER INDEX) is just the statement that hit it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I fix Msg 1934 in sqlcmd?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add the -I switch, which turns QUOTED_IDENTIFIER on for the classic ODBC sqlcmd, or put SET QUOTED_IDENTIFIER ON at the top of the script before the failing statement. The newer Go-based sqlcmd always has quoted identifiers enabled and ignores -I.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my application work but the same SQL fails in sqlcmd?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each client chooses its own session defaults. ADO.NET, Entity Framework Core, the ODBC driver, OLE DB and SSMS connect with QUOTED_IDENTIFIER ON, while the classic ODBC-based sqlcmd starts with it OFF unless you pass -I. Same database, different session settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a stored procedure still fail after I SET QUOTED_IDENTIFIER ON?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stored procedures, triggers and functions keep the QUOTED_IDENTIFIER and ANSI_NULLS settings that were in force when they were created, and a SET inside the body has no effect. Find them with sys.sql_modules WHERE uses_quoted_identifier = 0 and re-create each one from a session that has the option on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between SET QUOTED_IDENTIFIER ON and OFF?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With ON (the default for most clients) double quotes delimit identifiers such as "Order" and strings must use single quotes. With OFF, double quotes delimit string literals instead. Features like filtered indexes and indexed views require ON, which is why OFF causes error 1934 on those tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does ASP.NET Core Identity use filtered indexes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. On SQL Server, Identity's unique indexes on nullable columns are filtered with IS NOT NULL - UserNameIndex on AspNetUsers and RoleNameIndex on AspNetRoles. Any INSERT, UPDATE or DELETE on those tables from a session with QUOTED_IDENTIFIER OFF fails with Msg 1934.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I drop the filtered index to get rid of the error?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The filtered unique index is what stops two users sharing a username. Fix the session setting instead - SET QUOTED_IDENTIFIER ON, sqlcmd -I, or re-create the procedure - and the index keeps doing its job.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/sql-server-msg-1934-set-options-quoted-identifier" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>sqlserver</category>
      <category>dotnet</category>
      <category>database</category>
    </item>
    <item>
      <title>PHP2Shell Explained: How AI Found a PHP Bug Hiding Since 2012, and How It Was Fixed</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Thu, 22 Oct 2026 16:41:56 +0000</pubDate>
      <link>https://dev.to/kausarraza/php2shell-explained-how-ai-found-a-php-bug-hiding-since-2012-and-how-it-was-fixed-4ka6</link>
      <guid>https://dev.to/kausarraza/php2shell-explained-how-ai-found-a-php-bug-hiding-since-2012-and-how-it-was-fixed-4ka6</guid>
      <description>&lt;p&gt;On 21 October 2026 PHP shipped an emergency release for two bugs in the language itself: &lt;strong&gt;CVE-2026-109618&lt;/strong&gt; and &lt;strong&gt;CVE-2026-109619&lt;/strong&gt;, together nicknamed &lt;strong&gt;PHP2Shell&lt;/strong&gt;. Both are rated 9.2 (Critical) and both can give an attacker remote code execution with no login - on a server whose application code has no bug at all. Wiz, one of the teams that reported them, says every PHP version released in the last decade is affected by at least one of the two, going back to PHP 5.4 from 2012.&lt;/p&gt;

&lt;p&gt;The other reason this one made the evening news is how it was found. Three separate reports reached the PHP security team in August and September, and frontier AI models are credited with a large share of the finding, the exploiting and the checking. This article covers the bug itself, how it was found, how it was fixed, what you need to do, and whether AI is the real danger here.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you only read one line:&lt;/strong&gt; update to PHP &lt;strong&gt;8.5.12, 8.4.27, 8.3.36 or 8.2.35&lt;/strong&gt; (or your distribution's patched package), then restart every PHP process - Apache, PHP-FPM, queue workers. PHP's own advisory says there is no complete workaround: "updating is the only safe choice."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What PHP2Shell is, in plain English
&lt;/h2&gt;

&lt;p&gt;A PHP server does not start a fresh process for every visitor. It keeps a pool of worker processes alive and hands each new request to one of them. Every request gets its own memory, and PHP is supposed to clean that memory up completely before the worker takes the next request.&lt;/p&gt;

&lt;p&gt;Both bugs are failures of that clean-up, triggered at moments PHP did not expect to be interrupted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-109618 - "RCE via OOM corruption".&lt;/strong&gt; If a request runs out of memory (hits &lt;code&gt;memory_limit&lt;/code&gt;) at the wrong moment, PHP abandons it while some internal data is only half set up. The clean-up code then reads that half-built data as if it were valid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-109619 - "RCE via stale global state".&lt;/strong&gt; If a fatal error or an out-of-memory error interrupts a request at the wrong point, some of PHP's global state keeps pointing at memory that belonged to that request. When the same worker picks up the next request, it can use that freed memory before it is reset - a classic use-after-free, across two requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Corrupted or reused memory is the raw material for remote code execution: an attacker who can shape what ends up in that memory can steer the process. That is what "2Shell" in the name means - from an HTTP request to a shell on your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your application does not need a bug
&lt;/h2&gt;

&lt;p&gt;This is the part that makes PHP2Shell unusual. Most PHP security news is about a plugin, a framework or somebody's code. These two bugs are in the interpreter, and the dangerous paths run &lt;em&gt;before your code is reached&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multipart parsing happens during request startup.&lt;/strong&gt; When a request arrives with a file-upload style body, PHP parses it to fill &lt;code&gt;$_POST&lt;/code&gt; and &lt;code&gt;$_FILES&lt;/code&gt; before your script starts. The advisory for CVE-2026-109618 describes three ways an attacker can push that parsing into an out-of-memory error while staying inside limits that look safe: a body with no usable &lt;code&gt;Content-Length&lt;/code&gt; that keeps streaming, a part with an unlimited number of headers, and empty file fields that do not count towards &lt;code&gt;max_file_uploads&lt;/code&gt; but still allocate memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session upload progress runs without &lt;code&gt;session_start()&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;session.upload_progress&lt;/code&gt; feature is enabled by default and does its work during upload parsing. The CVE-2026-109619 advisory names it as one of the confirmed paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings applied per request can trip it.&lt;/strong&gt; On Apache with mod_php, &lt;code&gt;.htaccess&lt;/code&gt; and &lt;code&gt;php_value&lt;/code&gt; directives are applied before the request is fully initialised. A warning raised there can read the stale state from the previous request - and the advisory notes that hiding warnings with &lt;code&gt;error_reporting&lt;/code&gt;, &lt;code&gt;@&lt;/code&gt; or &lt;code&gt;display_errors&lt;/code&gt; does not help.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wiz says it got remote code execution against the stock WordPress, Drupal and Joomla container images and against a plain "Hello World" page. It also showed a second outcome that needs no code execution at all: leaking a worker's memory back to the attacker inside an oversized &lt;code&gt;PHPSESSID&lt;/code&gt; cookie. Worker memory can hold database passwords, API keys and other visitors' data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which setups are most exposed
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apache with mod_php&lt;/strong&gt; is the worst case. It runs request shutdown even when startup failed, so a single unauthenticated request can trigger CVE-2026-109618 before any application code runs. PHP says full RCE was demonstrated here with default settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP-FPM&lt;/strong&gt; is less exposed to the startup path, because a child that fails to start exits. The CVE-2026-109618 advisory says no complete FPM exploit was demonstrated; but the PHP Foundation confirms RCE on FPM through application-level code paths, and the CVE-2026-109619 advisory says the session-upload-progress RCE was validated against both mod_php and FPM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FrankenPHP and LiteSpeed (LSAPI)&lt;/strong&gt; are judged likely susceptible from source review, not validated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it was found
&lt;/h2&gt;

&lt;p&gt;The timeline below comes from the PHP Foundation's post and Wiz's write-up, which agree on the dates they share.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2024:&lt;/strong&gt; during PHP's first external security audit (by Quarkslab, organised by OSTIF and funded by Germany's Sovereign Tech Agency), a PHP core developer spotted the first signs of the out-of-memory problem while fixing a different report. It was not fixed at the time; reporting since disclosure says the team could not agree on a fix and nobody had shown it was exploitable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30 July 2026:&lt;/strong&gt; security firm Calif starts an audit of PHP under OpenAI's "Patch the Planet" programme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11 August:&lt;/strong&gt; Calif's Ryan Austin reports an unconditional remote code execution with the out-of-memory root cause. Coverage citing the Wall Street Journal and New York Times says he used OpenAI's GPT-5.6 Sol.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;19-24 August:&lt;/strong&gt; Wiz Research, working independently, finds its first PHP bug on 19 August, confirms unauthenticated RCE against Drupal the next day and WordPress the day after, and reports to PHP on 24 August. Wiz says it used its own Wiz Atlas tool together with Google's Gemini 4 Argon and GPT-5.6 Sol, and that the work was done by a single researcher in about a week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;18 September:&lt;/strong&gt; OpenAI reports further attack vectors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21 October:&lt;/strong&gt; patched releases and public disclosure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the AI actually did matters more than the headline. In Wiz's account the models contributed to finding the bugs, writing exploits, adapting those exploits to different PHP versions, configurations and memory layouts, detecting attacks and validating the patch - analysing their own failures and trying again. Humans chained the bugs together and did the per-target debugging. On the receiving end, the PHP security team also uses AI now: to validate incoming reports and group the ones with the same root cause. Their post is explicit that "a person makes every call and works on every patch."&lt;/p&gt;

&lt;p&gt;If you want to see what this kind of AI-driven bug hunting looks like in practice - a model driving a disassembler or a debugger on its own - I wrote up the tooling in &lt;a href="https://knowledgemarkg.com/blog/ai-reverse-engineering-ghidra-ida-mcp" rel="noopener noreferrer"&gt;AI reverse engineering: Ghidra MCP, IDA MCP and REA&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the disclosure went wrong
&lt;/h2&gt;

&lt;p&gt;Coordinated disclosure means the fix and the announcement land at the same moment, so defenders and attackers start level. PHP2Shell did not get that. In UTC, per the PHP Foundation, Patchstack and the reporting that followed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;17 October:&lt;/strong&gt; Amazon's MadPot honeypots recorded an exploitation attempt from an anonymising network - four days before disclosure. Amazon says it is unclear how the attacker knew, and advises investigating from at least 17 October, not from the release date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 October, ~15:00:&lt;/strong&gt; the CVE IDs leaked on forums and social media, with a wrong release time and a wrong CVSS score of 10.0. No technical details leaked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 October, 19:30:&lt;/strong&gt; PHP published its pre-announcement early and brought the release forward a day, to 21 October at 14:00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 October, ~20:00:&lt;/strong&gt; Calif mistakenly published a video of a working proof of concept, which the PHP Foundation says accelerated attackers' own proof-of-concept work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21 October, 07:41:&lt;/strong&gt; container vendor Chainguard published patched images more than six hours before the coordinated release - letting anyone compare old and new code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21 October, 07:43:&lt;/strong&gt; Cloudflare's CTO said it was seeing early attacks and had deployed WAF rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21 October, ~13:34-14:00:&lt;/strong&gt; advisories and releases went out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some operators chose to go dark rather than wait. Reported examples include the Dutch government portal Overheid.nl, Finnish public-facing government services, French Interior Ministry websites, parts of Wikimedia's supporting infrastructure, and the Internet Archive briefly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it being exploited?&lt;/strong&gt; The honest answer on 22 October is "reports conflict". CISA said on 21 October it had not observed exploitation. watchTowr said its honeypots saw exploitation by the same day. Patchstack reported that hosting partners had reported active incidents that morning, while its own telemetry after release showed probing rather than confirmed breaches. Plan as if exploitation is happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was fixed
&lt;/h2&gt;

&lt;p&gt;The fixes go after the root cause rather than the individual attack paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-memory no longer jumps out of the middle of anything.&lt;/strong&gt; PHP used a &lt;code&gt;longjmp()&lt;/code&gt; to abandon a request the instant an allocation failed - wherever it happened to be. The patch removes that. Hitting the limit now raises a delayed "soft" out-of-memory error at the next safe point in the virtual machine, where no half-built state is in flight, plus an immediate hard exit of the process (&lt;code&gt;_exit()&lt;/code&gt;) at a higher threshold. Either way, nothing reads half-initialised data afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request state is reset before request memory is freed.&lt;/strong&gt; The CVE-2026-109619 fix makes sure error, session, object and extension state that points into a request's memory is cleared before that memory is released, so the next request on the worker has nothing stale to find.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixed versions are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. PHP 8.1 and older are end of life and get no binaries, but because of the severity the PHP team published minimal source backports for 7.4, 8.0 and 8.1 for people who build from source. Linux distributions and long-term-support vendors received the patches in advance, so your distribution's package may already be fixed under its own version number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do now
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Find out which PHP actually serves your site
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;                 &lt;span class="c"&gt;# the command-line PHP - NOT necessarily what your web server runs&lt;/span&gt;
php-fpm8.3 &lt;span class="nt"&gt;-v&lt;/span&gt;          &lt;span class="c"&gt;# each FPM version installed (Debian/Ubuntu naming)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first command catches people out. The &lt;code&gt;php&lt;/code&gt; on your shell path and the PHP behind your website are often different builds, and many servers have several versions installed side by side. The reliable check is from the web side: a &lt;code&gt;phpinfo()&lt;/code&gt; page you delete straight afterwards, or the version shown in &lt;strong&gt;WordPress → Tools → Site Health → Info → Server&lt;/strong&gt;. On cPanel, MultiPHP Manager lists the version per domain; Plesk shows it in each domain's PHP settings. Update &lt;em&gt;every&lt;/em&gt; installed version, not just the one your site uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Update, then restart everything
&lt;/h3&gt;

&lt;p&gt;A new package on disk does nothing while old workers are still running the old code in memory. Restart Apache, every PHP-FPM pool and any long-running PHP process (queue workers, Laravel Octane, RoadRunner, Swoole). Rebuild and redeploy container images - a running container does not patch itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. If you genuinely cannot update today
&lt;/h3&gt;

&lt;p&gt;These come from PHP's own announcement. They block the known paths and nothing more; PHP is clear that they do not make a server safe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; php.ini, FPM pool or server config - not ini_set(), not .user.ini
&lt;/span&gt;&lt;span class="py"&gt;memory_limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;-1                    ; removes the controllable OOM trigger&lt;/span&gt;
&lt;span class="py"&gt;post_max_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8M                   ; 32M only if you need uploads&lt;/span&gt;
&lt;span class="py"&gt;upload_max_filesize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6M             ; example - keep it below post_max_size&lt;/span&gt;
&lt;span class="py"&gt;session.upload_progress.enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0  ; blocks the confirmed session paths&lt;/span&gt;
&lt;span class="py"&gt;fatal_error_backtraces&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0           ; PHP 8.5 only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply them where PHP reads them before the request starts, and restart every worker. Put request-size limits in front of PHP too, at the web server or proxy, because the limits have to cover bodies with no usable &lt;code&gt;Content-Length&lt;/code&gt;. And note the warning in the advisory: do not use &lt;code&gt;RLIMIT_AS&lt;/code&gt; or &lt;code&gt;RLIMIT_DATA&lt;/code&gt; as a substitute memory cap - they can cause exactly the allocation failure you are trying to avoid. If the server is high-value and none of this is possible, CISA's advice is to take it offline until it is patched.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Look back, starting from 17 October
&lt;/h3&gt;

&lt;p&gt;The signs Wiz, Patchstack and CISA list: unusual multipart requests followed by memory-limit errors, bursts of HTTP 500s, oversized or malformed &lt;code&gt;PHPSESSID&lt;/code&gt; cookies, unexplained PHP worker restarts, and - the one that matters most - a PHP process starting a shell or downloading tools. None of these proves anything on its own. If you see them, patch anyway, treat the server as possibly compromised, and rotate the secrets it holds, because a memory leak does not leave a broken page behind.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. "My site isn't PHP" - check the server anyway
&lt;/h3&gt;

&lt;p&gt;This site runs on ASP.NET Core and Next.js, so PHP2Shell does not touch its code. That is exactly the trap for anyone on shared or panel-managed hosting. A Plesk or cPanel server usually has PHP installed whether or not your site uses it - for webmail such as Roundcube, for phpMyAdmin, and for the other customers on the same machine. Ask your host which PHP builds are on your server and when they were patched. If you are deciding between a managed CMS and something custom partly on maintenance grounds, this is the kind of event that belongs in that decision - see &lt;a href="https://knowledgemarkg.com/blog/wordpress-vs-custom-website" rel="noopener noreferrer"&gt;WordPress vs a custom website&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is AI the danger here?
&lt;/h2&gt;

&lt;p&gt;Every headline asked it, so here is my reading of the evidence - and it is a reading, not a fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI did not create these bugs.&lt;/strong&gt; They were written by people, some of them over a decade ago, and a person had already seen the first sign of one in 2024. What stopped anyone fixing it was not a lack of intelligence; it was disagreement, missing proof of exploitability and too few people. The PHP Foundation says so itself: the delay "was a structural failure, caused by both the security team's decision process and its lack of resources."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What AI changed is the cost.&lt;/strong&gt; Wiz's account is one researcher, about a week, three of the biggest PHP applications exploited, with the models porting the exploit across versions. Turning "this looks like it might be exploitable" into a working attack used to be the expensive part. Here it was the fast part. The same economics apply to attackers - and they apply to the gap between a patch and an exploit. Security firm Sansec predicted "mass attacks to start within 15 minutes of the release"; Chainguard's early images handed out the patch diff hours ahead. When a model can read a diff and work out the bug, publishing a fix is publishing a map.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same tools are the defence.&lt;/strong&gt; All three reports came from defenders using AI, and PHP's own team uses AI to cope with the volume - it says it has handled more than 100 invalid, duplicate or unreproducible reports since July alongside 15 real CVEs. The capability is symmetrical. What is not symmetrical is funding: The Guardian pointed out that the foundation maintaining a language behind a huge share of the web had 13 full- and part-time staff, while the AI work credited with the discovery sits on billions in investment.&lt;/p&gt;

&lt;p&gt;So no, AI is not the threat on its own. The threat is the time between "a fix exists" and "your server runs it", and AI is shrinking every other clock in this story. Updates that used to be fine within a week now need to happen within hours, which mostly means automatic security updates, an inventory of everything that runs PHP, and a host that tells you when it has patched. If your process today is "someone remembers to check", PHP2Shell is the reason to change it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Two memory-safety bugs in PHP's request clean-up, present since 2012, let an unauthenticated request corrupt or reuse worker memory and take over the process - before your code runs. AI-assisted research found and weaponised them in days; a leaky disclosure gave attackers a head start; PHP fixed the root cause by no longer abandoning requests mid-operation and by resetting state properly. Update to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, restart every worker, and check for signs of trouble back to 17 October.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is PHP2Shell?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PHP2Shell is the nickname for two critical bugs in the PHP interpreter, CVE-2026-109618 and CVE-2026-109619, disclosed and patched on 21 October 2026. Both are rated CVSS 9.2 and can allow unauthenticated remote code execution on a PHP server, even when the application itself has no bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which PHP versions are affected by PHP2Shell?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All versions from PHP 5.4 through 8.5.11, according to Wiz and Google. The fixed releases are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. PHP 8.1 and older are end of life; the PHP team published source backports for 7.4, 8.0 and 8.1, and some Linux distributions and LTS vendors ship their own patched packages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is my WordPress site affected by PHP2Shell?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it runs on an unpatched PHP version, yes - the bug is in PHP, not WordPress, and Wiz demonstrated remote code execution against the stock WordPress image. Check the PHP version under Tools &amp;gt; Site Health &amp;gt; Info &amp;gt; Server, and ask your host to confirm it is 8.2.35, 8.3.36, 8.4.27, 8.5.12 or a patched distribution package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How was the PHP2Shell vulnerability found?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three teams reported it independently: Calif (11 August 2026, during an audit under OpenAI's Patch the Planet programme), Wiz Research (24 August) and OpenAI (18 September). Wiz says it used Gemini 4 Argon, GPT-5.6 Sol and its own Wiz Atlas tool, and that AI helped with discovery, exploit development and patch validation. A PHP developer had seen the first sign of the out-of-memory problem in a 2024 audit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I mitigate PHP2Shell if I cannot update?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PHP lists partial measures: memory_limit=-1 set where applications cannot override it, a low post_max_size, session.upload_progress.enabled=0, and fatal_error_backtraces=0 on PHP 8.5, all set in php.ini or server config and followed by a restart of every worker. PHP says these block known paths only and that updating is the only safe choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did AI create the PHP2Shell vulnerability?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The bugs were in human-written code dating back to PHP 5.4 in 2012. AI models were used by the researchers who found them, to locate the bugs, build and adapt exploits and validate the fix. The concern is that the same capability lowers the cost of exploitation for attackers and shortens the time between a patch and an attack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is PHP2Shell being exploited in the wild?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Reports conflict. Amazon recorded an exploitation attempt on its honeypots on 17 October, four days before disclosure; Cloudflare and watchTowr reported early attacks on 21 October; CISA said it had not observed exploitation as of 21 October. Treat it as actively exploited and check logs back to 17 October.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/php2shell-explained-how-ai-found-php-bug" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>DeepSeek Harness (dsh): What It Is, How to Install It, and What Is Actually Inside</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Tue, 20 Oct 2026 16:39:56 +0000</pubDate>
      <link>https://dev.to/kausarraza/deepseek-harness-dsh-what-it-is-how-to-install-it-and-what-is-actually-inside-3dcc</link>
      <guid>https://dev.to/kausarraza/deepseek-harness-dsh-what-it-is-how-to-install-it-and-what-is-actually-inside-3dcc</guid>
      <description>&lt;p&gt;DeepSeek Harness, or &lt;strong&gt;dsh&lt;/strong&gt;, is DeepSeek's open-source &lt;strong&gt;agent harness&lt;/strong&gt;: the software that wraps a language model and turns it into an agent that can read files, run commands, search the web and work on a task over many steps. Its design idea is unusual - &lt;em&gt;everything is a plugin&lt;/em&gt;, including the agent loop itself.&lt;/p&gt;

&lt;p&gt;I installed it on Windows, ran its command-line tool, and dumped the configuration of its web profile to see what it is really made of. This guide covers what an agent harness is, how to install dsh, what is inside it, which models it can use, how its permission modes work, and the safety warnings DeepSeek itself attaches to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an agent harness?
&lt;/h2&gt;

&lt;p&gt;A language model on its own only produces text. An &lt;strong&gt;agent harness&lt;/strong&gt; is everything around the model that lets it act:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The agent loop&lt;/strong&gt; - send the conversation to the model, run the tools it asks for, feed the results back, repeat until the task is done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt; - read and write files, run shell commands, search, fetch web pages, ask the user a question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context management&lt;/strong&gt; - deciding what goes into each request, and compacting old history when the conversation gets too long.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety&lt;/strong&gt; - sandboxing and approval prompts before risky actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A user interface&lt;/strong&gt; - a terminal, a web app, a desktop app or an API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude Code, OpenAI's Codex CLI, OpenCode and Hermes are all harnesses. The model is the engine; the harness is the rest of the car. Two harnesses running the same model can behave very differently, which is why "harness engineering" has become a topic of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes DeepSeek Harness different
&lt;/h2&gt;

&lt;p&gt;Most harnesses have a fixed core and an extension system bolted on. dsh is built on &lt;a href="https://github.com/cordiverse/cordis" rel="noopener noreferrer"&gt;Cordis&lt;/a&gt;, a plugin framework in which plugins contribute services, typed events and reversible effects to a shared context. DeepSeek's architecture document says there is "no privileged core to patch": the model adapter, the tool registry, the session log and the agent loop are all plugins, so each one can be swapped through configuration.&lt;/p&gt;

&lt;p&gt;In practice that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;profile&lt;/strong&gt; is a named stack of plugin bundles. The shipped profiles are &lt;code&gt;web&lt;/code&gt;, &lt;code&gt;headless&lt;/code&gt;, &lt;code&gt;sdk&lt;/code&gt;, &lt;code&gt;sdk-minimal&lt;/code&gt; and &lt;code&gt;acp&lt;/code&gt;, and the CLI help also shows a &lt;code&gt;tui&lt;/code&gt; example.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;patch&lt;/strong&gt; file (&lt;code&gt;cordis.patch.yml&lt;/code&gt;) overrides or adds plugin rows without forking anything.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;session log&lt;/strong&gt; is the source of truth: every model-visible input is logged as JSONL, so resume, fork and transcripts are all derived from one record.&lt;/li&gt;
&lt;li&gt;Capabilities sit behind &lt;strong&gt;seams&lt;/strong&gt;. Point the filesystem and subprocess providers at a remote sandbox, and Bash, the terminal and language-server tools all move with them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to install and run it
&lt;/h2&gt;

&lt;p&gt;You need Node.js. The quickest start is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @deepseek-ai/dsh web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That boots the web profile and serves the Web UI at &lt;code&gt;http://127.0.0.1:3080&lt;/code&gt; (add &lt;code&gt;--no-open&lt;/code&gt; to stop it opening a browser). Then go to &lt;strong&gt;Settings → Models&lt;/strong&gt;, add an API key, choose a workspace folder, and start a session. The agent cannot do anything until a workspace is selected.&lt;/p&gt;

&lt;p&gt;Other ways to run it, from the CLI's own help:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dsh headless "run the tests"              # answer one task, print the result, exit
dsh --profile sdk                         # JSON-RPC server for the Python SDK
dsh --profile acp                         # automation clients over ACP
dsh --profile web --dump-config           # print the composed plugin tree and exit
dsh plugin --profile tui add 
# install a plugin into a profile ```

 On my machine, `npx -y @deepseek-ai/dsh --version` reported **0.2.1-rc.2** and ran fine on Node.js 22.18.0. There is also an official Electron desktop app in the repository, and a crop of unofficial "DeepSeek Harness desktop" wrappers on GitHub - check that you are installing from `deepseek-ai/deepseek-harness` or the `@deepseek-ai/dsh` npm package, because several projects reuse the name.

## What is inside: 193 plugins

Running `dsh --profile web --dump-default-config` prints the full plugin tree without booting anything. On version 0.2.1-rc.2 the web profile is composed of **193 separate `@deepseek-ai/dsh-*` plugin packages**. Grouped by job:

- **Agent core:** `dsh-agent-loop`, `dsh-system-prompt`, `dsh-agent-instructions`, `dsh-agent-preset`, `dsh-plan-mode`, `dsh-persona`, `dsh-goal`.
- **Tools:** filesystem and file search, Bash and PowerShell (each with a persistent variant), web fetch and search, to-do lists, background jobs, scheduling, ask-the-user, skills and a workflow tool.
- **Subagents:** spawn and fork in-process subagents, and control them.
- **Context management:** basic compaction, a tool-result pruner, image offload and a token meter - the parts that keep long sessions from overflowing.
- **Safety:** a local sandbox, a sandbox policy, separate Bash and PowerShell sandboxes, a filesystem sandbox, user approval and permission presets.
- **Models:** a DeepSeek API-key adapter, a DeepSeek account adapter, a retry layer and an adapter called `dsh-llm-pi-ai` for other providers.
- **Sessions and storage:** JSONL persistence, SQLite queries, checkpoints, export, titles and telemetry (OpenTelemetry).
- **MCP:** a `dsh-mcp-resources` plugin is part of the profile.
- **The Web UI itself:** chat, file browser, terminal, document preview, plan view, settings pages - each one a plugin too.

That granularity is the point. If you want a different compaction strategy or a remote sandbox, you replace one row in the patch file rather than forking the project.

## Which models can it use?

DeepSeek's models are built in: **Settings → Models** has a DeepSeek card with a single API-key field, and DeepSeek's models offer "off", "low", "high" and "max" reasoning levels. It is not DeepSeek-only, though. According to the provider guide you can add:

- **Third-party providers** from a catalogue, including ids such as `anthropic`, `openai`, `moonshotai` (Kimi) and `zai` (GLM).
- **Custom endpoints** - a company gateway or a self-hosted server - speaking one of three protocols: OpenAI Chat Completions, OpenAI Responses or Anthropic Messages.

Keys are stored in `$DSH_HOME/.credentials.yaml`, and provider settings live in the profile's `cordis.patch.yml`.

## Permission modes

The dumped configuration shows three permission presets:

| Preset | Sandbox | Approval |
|---|---|---|
| `read-only` | read-only | ask |
| `workspace-write` (default) | workspace-write | ask |
| `danger-full-access` | full access | never |

The default comes from the `DSH_PERMISSION_MODE` environment variable and falls back to `workspace-write`, which means the agent can change files in your chosen workspace but asks before operations that need approval. Only `danger-full-access` turns approvals off - and the name is an honest warning.

## The safety notice: read it before you run it

DeepSeek describes dsh as experimental developer-preview software that has not had a security audit, and warns that there will be compatibility-breaking changes. Its safety notice is blunt: the harness can run model-generated commands, modify or delete files it can reach, and access the network and any credentials made available to it, and third-party plugins may be untrusted. Sandboxing and approvals reduce the risk but "cannot protect resources that the project is allowed to access".

The recommendations are the same ones I would give for any agent harness:

1. Run it in a disposable virtual machine or container for anything important.
2. Give it the narrowest workspace and the fewest credentials possible - never your production keys.
3. Keep backups of anything it can touch.
4. Review plugins and proposed commands before approving them.

## DeepSeek Harness vs Claude Code and OpenCode

People search for these comparisons, so here is what can be said honestly without running a benchmark:

- **Claude Code** is Anthropic's harness, tuned for Claude models, mature and terminal-first, with hooks, skills and MCP support. dsh is younger, open source under MIT, web-first, and designed around replacing any part of itself.
- **OpenCode** is an open-source, provider-agnostic terminal harness. dsh is also provider-agnostic, but its plugin model goes further: even the loop and the session log are swappable.
- **Maturity:** dsh is a developer preview (0.2.x release candidates at the time of writing). For day-to-day client work I would still reach for a mature harness; dsh is the one to study if you want to understand, or build, an agent harness.

I have not measured task success between them, so treat anyone's "X beats Y" chart with care unless they publish the tasks, the model and the settings.

## Who should try it?

- **Developers building their own agents** - the plugin tree is a working reference architecture.
- **Teams that need a self-hosted agent** pointed at their own model gateway.
- **DeepSeek API users** who want an official interface beyond the chat app.

If you only want to run DeepSeek's models locally without an agent, my guide to [running DeepSeek R1 locally on Mac, Windows and Linux](https://knowledgemarkg.com/blog/how-to-run-deepseek-r1-locally-for-free-on-macwindowslinux) is the simpler place to start, and [DeepSeek vs ChatGPT](https://knowledgemarkg.com/blog/deepseek-ai-vs-chatgpt-which-ai-best-fits-your-needs-2025) covers the models themselves. For the other big agent story of 2026 - agents driving disassemblers - see [AI reverse engineering with Ghidra and IDA](https://knowledgemarkg.com/blog/ai-reverse-engineering-ghidra-ida-mcp).

## FAQ

**What is DeepSeek Harness?**

DeepSeek Harness (dsh) is DeepSeek's open-source agent harness: the software around a language model that lets it read and edit files, run commands, search and work on multi-step tasks. It is built on the Cordis plugin framework, so every part - including the agent loop - is a plugin.

**How do I install DeepSeek Harness?**

With Node.js installed, run npx @deepseek-ai/dsh web. It serves the Web UI at http://127.0.0.1:3080. Add an API key in Settings, choose a workspace folder, and start a session.

**What is an agent harness?**

An agent harness is everything around a model that turns it into an agent: the loop that runs tools and feeds results back, the tools themselves, context management, sandboxing and approvals, and the user interface. Claude Code, Codex CLI, OpenCode and DeepSeek Harness are all harnesses.

**Does DeepSeek Harness only work with DeepSeek models?**

No. DeepSeek models are built in, but you can add third-party providers from its catalogue, including Anthropic, OpenAI, Kimi and GLM, or any custom endpoint that speaks the OpenAI Chat Completions, OpenAI Responses or Anthropic Messages protocol.

**Is DeepSeek Harness safe to use?**

Its own safety notice calls it experimental, unaudited developer-preview software that can run commands and modify files it can reach. The default mode asks before risky operations, but you should run it in a VM or container, give it minimal credentials, and keep backups.

**Is DeepSeek Harness free and open source?**

Yes, the harness is open source under the MIT licence. You pay only for the model API you connect, such as DeepSeek's API, unless you point it at a self-hosted model.

**What are the DeepSeek Harness permission modes?**

There are three presets: read-only and workspace-write, which both ask for approval before sensitive operations, and danger-full-access, which turns approvals off. The default is workspace-write, set by the DSH_PERMISSION_MODE environment variable.

---

_Originally published on [knowledgemarkg.com](https://knowledgemarkg.com/blog/deepseek-harness-dsh-guide)._
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
      <category>deepseek</category>
    </item>
    <item>
      <title>AI Reverse Engineering in 2026: How LLM Agents Drive Ghidra and IDA (and Where They Fail)</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Tue, 20 Oct 2026 16:39:17 +0000</pubDate>
      <link>https://dev.to/kausarraza/ai-reverse-engineering-in-2026-how-llm-agents-drive-ghidra-and-ida-and-where-they-fail-3270</link>
      <guid>https://dev.to/kausarraza/ai-reverse-engineering-in-2026-how-llm-agents-drive-ghidra-and-ida-and-where-they-fail-3270</guid>
      <description>&lt;p&gt;A year ago, "AI reverse engineering" meant pasting a decompiled function into a chat window and asking what it did. In 2026 it means something different: an agent such as Claude, Codex or a local model &lt;strong&gt;drives the disassembler itself&lt;/strong&gt; - it lists functions, follows cross-references, renames variables and reads pseudocode, one tool call at a time, through the Model Context Protocol (MCP).&lt;/p&gt;

&lt;p&gt;This guide explains how that works, which tools exist for Ghidra, IDA Pro, Binary Ninja and Android, what a real session looks like, what the first serious benchmark found, and the limits you will hit. It also covers the part most tutorials skip: what you are allowed to reverse engineer in the UK, the EU and the US.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "AI reverse engineering" actually means now
&lt;/h2&gt;

&lt;p&gt;Reverse engineering is recovering how a program works without its source code. The slow part has always been the reading: thousands of unnamed functions like &lt;code&gt;FUN_00401a30&lt;/code&gt;, registers instead of variables, and no comments. Language models are good at exactly that kind of pattern reading - they recognise a CRC loop, a string decoder or a licence check far faster than a person scrolling through assembly.&lt;/p&gt;

&lt;p&gt;What changed is &lt;strong&gt;tool access&lt;/strong&gt;. An MCP server exposes the disassembler's functions - "decompile this address", "list cross-references", "rename this symbol", "search strings" - as tools the model can call. The model is no longer guessing from a snippet you chose; it navigates the binary itself and comes back with findings and the evidence behind them.&lt;/p&gt;

&lt;p&gt;The usual stack has four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The agent&lt;/strong&gt; - Claude Code, Claude Desktop, Codex, Cursor or another MCP client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An MCP server&lt;/strong&gt; - the bridge that turns tool calls into disassembler actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The analysis engine&lt;/strong&gt; - Ghidra, IDA Pro, Binary Ninja, radare2, jadx or Frida.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The target&lt;/strong&gt; - a native binary, an APK, an Electron app, a firmware image or a network capture.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The main tools, by engine
&lt;/h2&gt;

&lt;p&gt;A community index of AI reverse engineering projects, &lt;a href="https://github.com/zhuyansen/awesome-ai-reverse-engineering" rel="noopener noreferrer"&gt;awesome-ai-reverse-engineering&lt;/a&gt;, lists 122 repositories; 74 of them are MCP servers or plugins for disassemblers. The ones you will meet first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;What it gives the agent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GhidraMCP (LaurieWired)&lt;/td&gt;
&lt;td&gt;Ghidra (free)&lt;/td&gt;
&lt;td&gt;Decompile, list functions and strings, rename symbols in a running Ghidra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IDA Pro MCP (mrexodia)&lt;/td&gt;
&lt;td&gt;IDA Pro 8.3+&lt;/td&gt;
&lt;td&gt;A broad tool set over IDA's analysis; IDA Free is not supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hex-Rays IDA MCP (official)&lt;/td&gt;
&lt;td&gt;Recent IDA versions&lt;/td&gt;
&lt;td&gt;Vendor-maintained server; newer and smaller community&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary Ninja MCP&lt;/td&gt;
&lt;td&gt;Binary Ninja&lt;/td&gt;
&lt;td&gt;Plugin with an embedded server; a headless variant exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jadx MCP&lt;/td&gt;
&lt;td&gt;jadx (Android)&lt;/td&gt;
&lt;td&gt;Decompiled Java/Kotlin classes, manifest, references&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frida MCP&lt;/td&gt;
&lt;td&gt;Frida&lt;/td&gt;
&lt;td&gt;Runtime hooks on a live process - observe what the code really does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;radare2 MCP&lt;/td&gt;
&lt;td&gt;radare2&lt;/td&gt;
&lt;td&gt;Command-line engine, scriptable and free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REA (morluto/rea)&lt;/td&gt;
&lt;td&gt;Hopper, Ghidra or IDA, plus built-in parsers&lt;/td&gt;
&lt;td&gt;One MCP for native binaries, JavaScript/Electron, .NET, APKs, websites and captures&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you only want to try the idea, &lt;strong&gt;Ghidra plus GhidraMCP&lt;/strong&gt; is the zero-cost route. If you already pay for IDA, the IDA servers are more capable. If your targets vary - an Electron app this week, a .NET DLL next week - a multi-engine MCP like REA saves you wiring up five separate servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real session: patching lives in River Raid
&lt;/h2&gt;

&lt;p&gt;The clearest public write-up so far is Quesma's &lt;a href="https://quesma.com/blog/ghidra-mcp-unlimited-lives/" rel="noopener noreferrer"&gt;River Raid case study&lt;/a&gt;. The author loaded the 8 KB Atari 8-bit ROM into Ghidra as raw 6502 code, connected Claude through GhidraMCP, and asked for infinite lives. What happened is a good summary of the whole field:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It found the right code.&lt;/strong&gt; Claude identified the Atari 8-bit platform from hardware register addresses and located the routine that loads, decrements and stores the lives counter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It knew the fix but could not apply it.&lt;/strong&gt; The patch was replacing &lt;code&gt;DEY&lt;/code&gt; (&lt;code&gt;$88&lt;/code&gt;) with &lt;code&gt;NOP&lt;/code&gt; (&lt;code&gt;$EA&lt;/code&gt;) at offset &lt;code&gt;0x355&lt;/code&gt;. The MCP server had no write access, so the human applied it with &lt;code&gt;dd&lt;/code&gt;. In the emulator, the lives stayed at 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It needed help with setup.&lt;/strong&gt; Ghidra loaded the ROM at &lt;code&gt;$0000&lt;/code&gt; instead of &lt;code&gt;$A000&lt;/code&gt;, so every cross-reference pointed nowhere. Claude diagnosed the problem correctly but could not rebase the program itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It was confidently wrong once.&lt;/strong&gt; It guessed the game was Centipede. The author's conclusion is worth remembering: confidence and accuracy are unrelated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It was slow.&lt;/strong&gt; Some tool calls took around 30 seconds - fine for a batch task, painful interactively.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That pattern - excellent at reading and locating, weak at broad context and anything that needs write access - matches what most people report.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first benchmark says
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2605.10597" rel="noopener noreferrer"&gt;CrackMeBench&lt;/a&gt; (2026) tests agents on symbol-poor CrackMe binaries built from C, Rust and Go templates. Each agent gets a shell in a network-less Docker sandbox with standard reverse engineering tools, a five-minute budget per task and three scored submissions. The program itself is the judge: the agent must produce a serial or key generator the binary accepts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Generated tasks (12)&lt;/th&gt;
&lt;th&gt;Harder half (6)&lt;/th&gt;
&lt;th&gt;Public CrackMes (8)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;11/12&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;td&gt;3/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.7&lt;/td&gt;
&lt;td&gt;7/12&lt;/td&gt;
&lt;td&gt;2/6&lt;/td&gt;
&lt;td&gt;2/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K2&lt;/td&gt;
&lt;td&gt;5/12&lt;/td&gt;
&lt;td&gt;1/6&lt;/td&gt;
&lt;td&gt;1/8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things stand out. Results drop sharply on the harder and the real-world public tasks, and the gap between models is large. Treat any single leaderboard as a snapshot, though - the tasks are small, the budget is five minutes, and models change every few months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it up: the part that takes longest
&lt;/h2&gt;

&lt;p&gt;Every tool follows the same shape: install the engine, install its plugin or extension, start the MCP server, then register that server with your agent. In Claude Code that last step is &lt;code&gt;claude mcp add&lt;/code&gt;; in Claude Desktop it is the MCP section of the config file. Follow each project's README for the exact commands - they change between releases, and the Quesma author counted four separate points of failure in the chain (client, MCP server, Ghidra extension, Ghidra).&lt;/p&gt;

&lt;p&gt;REA tries to collapse that into one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx rea-agents setup &lt;span class="nt"&gt;--dry-run&lt;/span&gt;     &lt;span class="c"&gt;# print the plan, change nothing&lt;/span&gt;
npx rea-agents setup               &lt;span class="c"&gt;# register the MCP server with your agents&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried exactly that on Windows with Node.js 22.18.0 to inspect my own website's build output. The dry run planned nothing and returned &lt;code&gt;status: needs_human&lt;/code&gt; with the remediation "Install Node.js 22.x (&amp;gt;=22.19), 24.x (&amp;gt;=24.11), or 26+". Two lessons: check the runtime requirement before anything else, and always run the dry run first - a setup tool that edits your agent's configuration should show you its plan before it writes a byte.&lt;/p&gt;

&lt;p&gt;Once a server is registered, start small. Good first prompts are "list the exported functions and summarise what each module does" and "find where this string is used and explain the check around it". Ask for addresses and evidence with every claim, so you can verify instead of trusting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI reverse engineering is genuinely useful
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Triage.&lt;/strong&gt; Getting a first map of an unfamiliar binary - which functions matter, where the network code is, what the strings suggest - in minutes rather than an afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Naming and annotation.&lt;/strong&gt; Proposing names for hundreds of functions and variables, which a human then reviews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explaining known patterns.&lt;/strong&gt; Crypto routines, compression loops, custom string encoding, licence checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own software.&lt;/strong&gt; Understanding a legacy binary whose source is lost, checking what a build really contains, or finding why a minified JavaScript bundle is so large. As a web developer, that last one is where I would use it most: the same tools that read malware can read your own production bundles.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The limits you will hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Confident mistakes.&lt;/strong&gt; Always ask for the address and the instructions behind a claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write access.&lt;/strong&gt; Many servers are deliberately read-only. Patching, rebasing and retyping often stay manual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed and cost.&lt;/strong&gt; Large binaries mean many tool calls; each one costs time and tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Obfuscation and packing.&lt;/strong&gt; Packed or virtualised code defeats the model just as it defeats static analysis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt injection.&lt;/strong&gt; A binary can contain strings written to manipulate the agent reading it. Researchers have published attacks on exactly these setups. Never give a reverse engineering agent your real credentials or an unrestricted shell, and analyse untrusted samples in a disposable virtual machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is it legal? UK, EU and US basics
&lt;/h2&gt;

&lt;p&gt;This is general information, not legal advice - get proper advice before reverse engineering anything commercial.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UK:&lt;/strong&gt; the Copyright, Designs and Patents Act 1988 (section 50B) allows decompiling a program to achieve interoperability with another program, under conditions, and section 50BA allows observing and testing a program's behaviour if you have the right to use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EU:&lt;/strong&gt; the Software Directive (2009/24/EC, Article 6) gives a similar, narrow interoperability exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;US:&lt;/strong&gt; reverse engineering for interoperability has been accepted as fair use in several cases, but the DMCA's anti-circumvention rules (section 1201) apply when protection measures are involved, with specific exemptions such as good-faith security research.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Licence terms matter too: many end-user licences forbid reverse engineering, and the AI provider's own usage policy applies to what you ask the model to do. Analysing your own code, open-source software, CTF challenges or malware samples for defence is the safe ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Install Ghidra and pick a target you are allowed to analyse - a CrackMe or a CTF binary is ideal.&lt;/li&gt;
&lt;li&gt;Connect GhidraMCP to Claude Code or Claude Desktop and ask for a function map with addresses.&lt;/li&gt;
&lt;li&gt;Verify three of the agent's claims by hand before trusting the fourth.&lt;/li&gt;
&lt;li&gt;When your targets diversify, look at a multi-engine MCP such as REA - after checking its Node.js requirement and running the dry run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent-harness side of this story is moving just as fast. If you want to see how an open agent framework is put together from the inside, read my teardown of &lt;a href="https://knowledgemarkg.com/blog/deepseek-harness-dsh-guide" rel="noopener noreferrer"&gt;DeepSeek Harness&lt;/a&gt;. And if you are curious what this kind of bundle inspection finds on a real website, here is how I &lt;a href="https://knowledgemarkg.com/blog/reduce-unused-javascript-nextjs-lcp-render-delay" rel="noopener noreferrer"&gt;fixed unused JavaScript and LCP render delay in Next.js&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is AI reverse engineering?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is using a language model to analyse compiled software. In 2026 that usually means an agent such as Claude driving a disassembler like Ghidra or IDA through an MCP server - listing functions, following cross-references and reading decompiled code - and reporting findings with the addresses behind them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Ghidra MCP?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GhidraMCP is an open-source Model Context Protocol server that connects an AI agent to a running Ghidra instance, so the agent can decompile functions, search strings and rename symbols itself instead of working from snippets you paste in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Claude reverse engineer software?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, when it is connected to a disassembler through MCP. It is strong at recognising patterns and locating the code that matters, weaker at broad context, and many MCP servers are read-only, so patching often stays manual. Always ask for evidence and verify its claims.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Ghidra MCP or IDA Pro MCP better?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ghidra with GhidraMCP is free and simple, which makes it the best way to start. IDA Pro MCP offers a broader tool set but needs a paid IDA Pro licence (IDA Free is not supported). There is no published head-to-head benchmark of the two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is REA (Reverse Engineer Anything)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;REA is an open-source MCP and CLI that gives an agent one set of reverse engineering tools across native binaries, JavaScript and Electron apps, .NET assemblies, Android APKs, websites and network captures. It uses Hopper, Ghidra or IDA for deep native analysis. Setup is npx rea-agents setup, which needs Node.js 22.19 or newer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How accurate are AI agents at reverse engineering?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the CrackMeBench benchmark, GPT-5.5 solved 11 of 12 generated tasks, Claude Opus 4.7 solved 7 and Kimi K2 solved 5, but every model scored far lower on harder and real-world tasks. Treat results as a snapshot and verify the agent's work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it legal to reverse engineer software with AI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It depends on what and why. The UK, EU and US all allow some reverse engineering for interoperability and research, but licence terms and anti-circumvention laws can restrict it. Analysing your own code, open-source software, CTF challenges or malware for defence is safe ground; get legal advice for anything commercial.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/ai-reverse-engineering-ghidra-ida-mcp" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>reverseengineering</category>
      <category>mcp</category>
    </item>
    <item>
      <title>There Is Already an Object Named 'X' in the Database: What EF Core Is Really Telling You</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Wed, 14 Oct 2026 16:10:55 +0000</pubDate>
      <link>https://dev.to/kausarraza/there-is-already-an-object-named-x-in-the-database-what-ef-core-is-really-telling-you-5bfh</link>
      <guid>https://dev.to/kausarraza/there-is-already-an-object-named-x-in-the-database-what-ef-core-is-really-telling-you-5bfh</guid>
      <description>&lt;p&gt;You run &lt;code&gt;dotnet ef database update&lt;/code&gt; against a database that already has a schema,&lt;br&gt;
and it stops with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Msg 2714, Level 16, State 1, Line 5
There is already an object named 'BlogPosts' in the database.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The instinct is to go and look at the migration. That is the wrong place. The migration is&lt;br&gt;
almost certainly fine. What has gone wrong is that EF Core's record of &lt;em&gt;what it has already&lt;br&gt;
applied&lt;/em&gt; no longer matches the migration files sitting in your project, so it is trying to&lt;br&gt;
run something for the second time.&lt;/p&gt;

&lt;p&gt;This happened on this site's own database, and the evidence is still in the table as I write&lt;br&gt;
this. I will show you the real rows further down.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same bug wears three different faces
&lt;/h2&gt;

&lt;p&gt;Which error message you get depends entirely on what the migration being re-run happens to do.&lt;br&gt;
That is why this problem is so hard to search for - three people hit the identical bug and go&lt;br&gt;
looking for three unrelated strings.&lt;/p&gt;

&lt;p&gt;I captured all three from a live SQL Server 2022 rather than quoting them from memory, using a&lt;br&gt;
temporary table so nothing real was touched:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Migration operation&lt;/th&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CreateTable&lt;/td&gt;
&lt;td&gt;2714&lt;/td&gt;
&lt;td&gt;There is already an object named 'X' in the database.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AddColumn&lt;/td&gt;
&lt;td&gt;2705&lt;/td&gt;
&lt;td&gt;Column names in each table must be unique. Column name 'ResumeUrl' in table 'SiteSettings' is specified more than once.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CreateIndex&lt;/td&gt;
&lt;td&gt;1913&lt;/td&gt;
&lt;td&gt;The operation failed because an index or statistics with name 'IX_BlogPosts_Slug' already exists on table 'BlogPosts'.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One quirk worth knowing if you ever try to reproduce this: 2714 is raised when the batch is&lt;br&gt;
compiled, not when it runs, so a &lt;code&gt;TRY...CATCH&lt;/code&gt; in the same batch will not catch it. The&lt;br&gt;
other two will. It is a small detail, but it is the kind that makes you think your error handling&lt;br&gt;
is broken when it is working exactly as designed.&lt;/p&gt;
&lt;h2&gt;
  
  
  What __EFMigrationsHistory actually is
&lt;/h2&gt;

&lt;p&gt;EF Core has no magic here. Its entire memory of your database is one small table that it&lt;br&gt;
creates itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;MigrationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProductVersion&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;__EFMigrationsHistory&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;MigrationId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two columns. &lt;code&gt;MigrationId&lt;/code&gt; is the filename of the migration without the extension -&lt;br&gt;
a 14-digit timestamp, an underscore, and the class name. &lt;code&gt;ProductVersion&lt;/code&gt; is the EF&lt;br&gt;
version of the tooling that applied it.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;database update&lt;/code&gt;, EF reads every &lt;code&gt;MigrationId&lt;/code&gt; out of that&lt;br&gt;
table, compares the list against the migration classes compiled into your assembly, and runs&lt;br&gt;
whatever is in the assembly but not in the table. That is the whole algorithm.&lt;/p&gt;

&lt;p&gt;Which means the error is not really "this object exists". It is "I have no row saying I created&lt;br&gt;
this object, so I am going to create it".&lt;/p&gt;
&lt;h2&gt;
  
  
  The real cause: your file and your history row drifted apart
&lt;/h2&gt;

&lt;p&gt;Here is the actual history table behind this site, trimmed to the interesting part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MigrationId                                    ProductVersion
---------------------------------------------  --------------
20260719093058_AddLogoDarkUrlToSiteSettings     9.0.0
20260920154152_AddResumeToSiteSettings          9.0.9
20260920154332_AddResumeToSiteSettings          9.0.9
20260922181332_AddLeadFieldsToContactMessages   9.0.9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the middle two. The same migration name, twice, with timestamps one minute and forty&lt;br&gt;
seconds apart. There is only one file on disk -&lt;br&gt;
&lt;code&gt;20260920154332_AddResumeToSiteSettings.cs&lt;/code&gt;. The &lt;code&gt;154152&lt;/code&gt; row is a ghost.&lt;/p&gt;

&lt;p&gt;What happened is mundane and extremely common: the migration was generated, applied, then&lt;br&gt;
something about it was wrong, so it was deleted and generated again. The regenerated file got a&lt;br&gt;
fresh timestamp, because the timestamp is simply the moment &lt;code&gt;migrations add&lt;/code&gt; ran. As&lt;br&gt;
far as EF is concerned that is a completely different migration which has never been applied - and&lt;br&gt;
its &lt;code&gt;Up&lt;/code&gt; method said &lt;code&gt;AddColumn("ResumeUrl")&lt;/code&gt; against a table that already&lt;br&gt;
had the column.&lt;/p&gt;

&lt;p&gt;That is error 2705, and this is the version of the story where it was caught. Run the same&lt;br&gt;
mistake on a migration that creates tables instead and you get 2714.&lt;/p&gt;

&lt;p&gt;Other routes to the same state, all of which I have run into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The database was built by hand or restored from a backup, and the first migration tries to
create what is already there.&lt;/li&gt;
&lt;li&gt;Two branches each added a migration, both were applied in different environments, and the
merge left one environment's history out of step.&lt;/li&gt;
&lt;li&gt;A migration was applied, then reverted in source control but never reverted in the
database.&lt;/li&gt;
&lt;li&gt;Someone ran &lt;code&gt;database update&lt;/code&gt; against the wrong connection string. On shared
hosting, with staging and production on one instance, this is easier than it sounds.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Why dotnet ef migrations list will not show you this
&lt;/h2&gt;

&lt;p&gt;This is the part that cost the most time, so I will be blunt about it. The obvious diagnostic&lt;br&gt;
does not work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet ef migrations list &lt;span class="nt"&gt;--project&lt;/span&gt; MyApp.Core &lt;span class="nt"&gt;--startup-project&lt;/span&gt; MyApp.Api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the database above, that command printed eighteen migrations. The ghost row was not one of&lt;br&gt;
them. It cannot be: the command builds its list from the migration classes in your assembly and&lt;br&gt;
then annotates which of them are pending. A history row with no matching class is not in the&lt;br&gt;
assembly, so there is nothing to annotate and it never appears.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;migrations list&lt;/code&gt; answers "which of my files have been applied". It does not&lt;br&gt;
answer "what does the database think it has applied", and the second question is the one you need.&lt;br&gt;
You have to go and ask the table yourself.&lt;/p&gt;
&lt;h2&gt;
  
  
  The diagnosis, in one query
&lt;/h2&gt;

&lt;p&gt;Read the history table and compare it with your files. Nothing here writes anything, so it is&lt;br&gt;
safe to run against production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;MigrationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProductVersion&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;__EFMigrationsHistory&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;MigrationId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then list the files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls &lt;/span&gt;Migrations/&lt;span class="k"&gt;*&lt;/span&gt;.cs | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; Designer | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; Snapshot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes, and each one has a different fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A file with no row.&lt;/strong&gt; EF will try to apply it on the next update. If the schema
it creates is already there, that is your error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A row with no file.&lt;/strong&gt; Harmless on its own - EF ignores rows it does not
recognise - but it is a loud signal that a migration was regenerated, and the &lt;em&gt;replacement&lt;/em&gt;
is probably case 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate names with different timestamps.&lt;/strong&gt; The two above, together. This is
the regenerated-migration case and it is the most common one by a distance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check &lt;code&gt;ProductVersion&lt;/code&gt; while you are in there, too. Mixed values across rows mean&lt;br&gt;
different machines with different EF tooling versions have been applying migrations to the same&lt;br&gt;
database, which is its own source of surprises.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fixing it without dropping the database
&lt;/h2&gt;

&lt;p&gt;The fix depends on which of two things is true, and you have to decide which before you type&lt;br&gt;
anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the schema is already correct and only the history row is missing&lt;/strong&gt; - the&lt;br&gt;
usual case - you do not want to run the migration at all. You want to tell EF it has already been&lt;br&gt;
run. Insert the row yourself, idempotently, so re-running the script is harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;__EFMigrationsHistory&lt;/span&gt;
               &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;MigrationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;'20260920154332_AddResumeToSiteSettings'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;__EFMigrationsHistory&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MigrationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProductVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;'20260920154332_AddResumeToSiteSettings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;'9.0.9'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;MigrationId&lt;/code&gt; must match the filename exactly, timestamp included.&lt;br&gt;
&lt;code&gt;ProductVersion&lt;/code&gt; should match the EF version you are running; it is informational, but&lt;br&gt;
getting it wrong makes the table lie to the next person who reads it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the history row exists but the schema does not&lt;/strong&gt; - someone restored an older&lt;br&gt;
backup over a newer history, say - then deleting the row is what lets EF apply the migration&lt;br&gt;
properly. Take a backup first. This is the direction in which guessing loses data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you have an orphan row and no corresponding file&lt;/strong&gt;, leaving it alone is a&lt;br&gt;
legitimate choice. EF will not trip over it. The &lt;code&gt;154152&lt;/code&gt; row is still in this site's&lt;br&gt;
database for exactly that reason: it is harmless, and it documents what happened. If it bothers&lt;br&gt;
you, delete it - but delete it because you have confirmed no file matches it, not because it looks&lt;br&gt;
untidy.&lt;/p&gt;

&lt;p&gt;To generate the SQL without applying it, and read what EF intends to do first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet ef migrations script 20260719093058_AddLogoDarkUrlToSiteSettings 20260920154332_AddResumeToSiteSettings &lt;span class="nt"&gt;--idempotent&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; check.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--idempotent&lt;/code&gt; wraps every operation in a check against the history table, which&lt;br&gt;
makes the script safe to run more than once. On a production database it is the only form worth&lt;br&gt;
running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three fixes to not reach for
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dotnet ef database drop&lt;/code&gt; ends the error by destroying the data. It is the right&lt;br&gt;
answer on a local development database you can reseed in a minute, and the wrong answer everywhere&lt;br&gt;
else. The two commands sit next to each other in the documentation, which is how accidents&lt;br&gt;
happen.&lt;/p&gt;

&lt;p&gt;Deleting the &lt;code&gt;Migrations&lt;/code&gt; folder and starting again feels like a reset. It is not -&lt;br&gt;
the history table survives, so a fresh &lt;code&gt;InitialCreate&lt;/code&gt; immediately collides with the&lt;br&gt;
existing schema and you are back at 2714 with less information than before.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;--ignore-changes&lt;/code&gt;, or hand-emptying the &lt;code&gt;Up&lt;/code&gt; method to make the&lt;br&gt;
error go away, is the one that causes real damage later. You end up with a migration that claims&lt;br&gt;
to do something and does nothing, and the next environment you deploy to is missing the schema&lt;br&gt;
with no error to tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it from happening again
&lt;/h2&gt;

&lt;p&gt;One rule covers most of it: &lt;strong&gt;never regenerate a migration that has already been applied&lt;br&gt;
anywhere&lt;/strong&gt;. Once a migration has run against a database you cannot reach and fix by hand,&lt;br&gt;
its timestamp is a fact about the world. If the migration is wrong, add a new one that corrects&lt;br&gt;
it. &lt;code&gt;migrations remove&lt;/code&gt; is only safe while the migration is still unapplied&lt;br&gt;
everywhere.&lt;/p&gt;

&lt;p&gt;Three more habits that have earned their place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Script the deployment with &lt;code&gt;migrations script --idempotent&lt;/code&gt; rather than running
&lt;code&gt;database update&lt;/code&gt; against production. You get to read it first, and it is safe to run
twice if a deploy is interrupted.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;__EFMigrationsHistory&lt;/code&gt; as part of a release, not as part of an incident. The
query above takes two seconds.&lt;/li&gt;
&lt;li&gt;Be deliberate about which connection string is active. On shared hosting, where staging and
production often sit on the same instance, the only thing between you and the wrong database is
which &lt;code&gt;appsettings&lt;/code&gt; file happened to load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are working on the same stack, the &lt;a href="https://knowledgemarkg.com/blog/database-indexes-and-idempotency" rel="noopener noreferrer"&gt;post&lt;br&gt;
on indexes and idempotency&lt;/a&gt; covers why idempotent scripts matter well beyond migrations, and&lt;br&gt;
&lt;a href="https://knowledgemarkg.com/blog/deploy-aspnet-core-plesk-500-errors" rel="noopener noreferrer"&gt;the ASP.NET Core deployment errors post&lt;/a&gt;&lt;br&gt;
covers the failures that show up immediately after this one, when the application starts but the&lt;br&gt;
database will not cooperate.&lt;/p&gt;

&lt;p&gt;The short version: when EF says an object already exists, it is not describing your schema. It&lt;br&gt;
is telling you it has lost track of its own history. Read the table, find the disagreement, and&lt;br&gt;
fix the record rather than the migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I just delete the migration file and create a new one?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only if the migration has never been applied to any database. Once it has run somewhere you cannot reach and correct by hand, regenerating it creates a second migration with a new timestamp that EF treats as unapplied - which is the exact cause of this error. Add a corrective migration instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to insert rows into __EFMigrationsHistory manually?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, when you have confirmed the schema that migration creates is already present. You are recording a fact that is already true. Use an IF NOT EXISTS guard so the script can be run twice, and make MigrationId match the filename character for character, including the timestamp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does dotnet ef migrations list not show the extra row?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because it builds its list from the migration classes compiled into your assembly and then marks which ones are pending. A history row with no matching class is not in the assembly, so it never appears in the output. You have to query __EFMigrationsHistory directly to see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between error 2714, 2705 and 1913?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing, as far as the cause goes - they are the same re-applied migration hitting different operations. 2714 comes from CreateTable, 2705 from AddColumn and 1913 from CreateIndex. Searching the message text sends three people with one problem to three unrelated places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does --idempotent fix this error?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It prevents it rather than fixing it. An idempotent script wraps every operation in a check against the history table, so operations EF has already recorded are skipped. It will not repair history that has already drifted - do that first, then use idempotent scripts so it does not drift again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My database was created by hand and now the first migration fails. What now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is case 1: the schema exists, the history row does not. Generate the script for InitialCreate and read it against your actual schema. If they match, insert the history row and skip the migration. If they differ, reconcile the differences in a new migration before recording anything as applied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I run dotnet ef database update against production?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prefer migrations script --idempotent and apply the SQL deliberately. You get to read exactly what will run, the script is safe to re-run if a deploy is interrupted, and you are not relying on a build machine holding a production connection string.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/ef-core-there-is-already-an-object-named-in-the-database" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>Database Indexes and Idempotency: What They Fix, and How to Get Them Wrong</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:54:19 +0000</pubDate>
      <link>https://dev.to/kausarraza/database-indexes-and-idempotency-what-they-fix-and-how-to-get-them-wrong-6b6</link>
      <guid>https://dev.to/kausarraza/database-indexes-and-idempotency-what-they-fix-and-how-to-get-them-wrong-6b6</guid>
      <description>&lt;p&gt;Two things quietly decide whether a small application survives being used: whether its reads are indexed, and whether its writes can be repeated safely. They are usually taught as separate subjects - one belongs to databases, the other to API design. They are the same subject seen twice. An index is how reads survive scale. Idempotency is how writes survive retries.&lt;/p&gt;

&lt;p&gt;Every example below is from the application that serves this page, including a failure that happened while it was being written.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Updated 22 October 2026:&lt;/strong&gt; the idempotency sections now cover concurrent duplicates - two identical requests arriving at the same moment - and why a &lt;code&gt;UNIQUE&lt;/code&gt; constraint, not a check in your code, is what actually stops them. They also cover what no database transaction can protect: a card charge or an email sent from inside it. Thanks to Kashif Manzer for pointing out the race in the comments below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Part one: what an index actually is
&lt;/h2&gt;

&lt;p&gt;The mental model that gets people into trouble is "an index makes the table faster". It does not. An index is a second, ordered copy of some of your columns, with a pointer back to the row. Adding one gives the query planner a new route to the data. It does not change the table.&lt;/p&gt;

&lt;p&gt;Two consequences follow immediately, and both matter more than the speedup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every index is paid for on write.&lt;/strong&gt; An insert into a table with four nonclustered indexes writes to five structures, not one. An index nobody reads is a pure tax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An index only helps if the query can use it.&lt;/strong&gt; Wrapping the indexed column in a function - &lt;code&gt;WHERE YEAR(PublishedAt) = 2026&lt;/code&gt; - throws the ordering away and the planner scans anyway. So does a leading wildcard in a &lt;code&gt;LIKE&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The problem an index solves, concretely
&lt;/h3&gt;

&lt;p&gt;This site lists blog posts. The query behind that list is, in effect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;BlogPosts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;IsPublished&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a useful index the database reads every row to find the published ones, then sorts the survivors. At a hundred rows nobody notices. At fifty thousand you are reading fifty thousand rows and sorting most of them to return ten.&lt;/p&gt;

&lt;p&gt;With the right index the database walks a structure that is already filtered and already in the right order, takes the first ten entries, and stops.&lt;/p&gt;

&lt;h3&gt;
  
  
  Composite indexes, and why column order is the whole game
&lt;/h3&gt;

&lt;p&gt;These three indexes shipped with this application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IX_BlogPosts_IsPublished_PublishedAt             (IsPublished, PublishedAt)
IX_BlogPosts_IsPublished_IsFeatured_PublishedAt  (IsPublished, IsFeatured, PublishedAt)
IX_BlogPosts_AuthorId_IsPublished                (AuthorId, IsPublished)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the first one. The order is not arbitrary and it is not alphabetical. A composite index is sorted by its first column, then within that by its second - like a phone book sorted by surname, then first name.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;(IsPublished, PublishedAt)&lt;/code&gt; puts all the published rows together, and inside that group they are already in date order. The query above filters on the first column and sorts on the second, so the database seeks to the published section and reads straight down it. No sort step at all.&lt;/p&gt;

&lt;p&gt;Reverse it to &lt;code&gt;(PublishedAt, IsPublished)&lt;/code&gt; and the index loses the property that mattered. The rows are now ordered by date first, with published and unpublished rows interleaved, so there is no contiguous block of published rows to seek to. The database can still read the index newest-first and throw drafts away as it goes - cheap while almost every row is published, expensive once most of them are drafts or scheduled posts, because it has to wade through all of them to collect ten survivors. The first arrangement costs the same however many drafts exist.&lt;/p&gt;

&lt;p&gt;Same two columns. Same table. One arrangement seeks straight to the answer; the other has to read past everything that is not the answer. &lt;strong&gt;The rule worth memorising: columns you filter by with equality come first, the column you sort or range-scan on comes last.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the third index exists separately
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;(AuthorId, IsPublished)&lt;/code&gt; could look redundant next to the others. It is not, because of the leftmost-prefix rule: an index on &lt;code&gt;(A, B)&lt;/code&gt; can serve queries filtering on &lt;code&gt;A&lt;/code&gt;, or on &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;B&lt;/code&gt; - but it cannot seek on &lt;code&gt;B&lt;/code&gt; alone. The author page filters by author first, so it needs an index that leads with &lt;code&gt;AuthorId&lt;/code&gt;. No amount of indexes leading with &lt;code&gt;IsPublished&lt;/code&gt; will help it.&lt;/p&gt;

&lt;h3&gt;
  
  
  When not to add one
&lt;/h3&gt;

&lt;p&gt;Indexes are not free and the instinct to add one per column is how tables get slow at writing without getting faster at reading. Skip them on small tables, on a column with very few distinct values on its own (a bit column is a fine leading column of a composite and a poor index by itself), and on anything you have not actually seen in a slow query. Add them from evidence - an execution plan, a slow query log - rather than from intuition.&lt;/p&gt;

&lt;p&gt;This table is its own example. Alongside the composites it still carries the single-column &lt;code&gt;IX_BlogPosts_IsPublished&lt;/code&gt; from the first migration. By the leftmost-prefix rule, &lt;code&gt;(IsPublished, PublishedAt)&lt;/code&gt; can already answer anything that index can, so it is now a write cost with no read benefit - a candidate to drop once the query plans confirm nothing still chooses it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other job an index does: a unique index is a rule
&lt;/h3&gt;

&lt;p&gt;Everything above is about indexes as a route to data. A &lt;code&gt;UNIQUE&lt;/code&gt; index is also a rule the database enforces on every write: no two rows may share that value, however and whenever they arrive. That second job is the one Part two depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part two: idempotency, and the problem it actually solves
&lt;/h2&gt;

&lt;p&gt;An operation is idempotent when doing it twice leaves the same result as doing it once. Reading is naturally idempotent. Setting a value is idempotent. Appending is not. Charging a card is emphatically not.&lt;/p&gt;

&lt;p&gt;The problem it solves is narrower and more common than "duplicate clicks". It is this: &lt;strong&gt;a network gives you no way to tell a request that failed from a response that was lost.&lt;/strong&gt; Your client sends a request, waits, and gets a timeout. Did the server do the work? You cannot know from the client. And the moment you retry - and HTTP clients, load balancers and message queues all retry - you are betting the outcome on that unanswerable question.&lt;/p&gt;

&lt;p&gt;Idempotency removes the question. If the operation is safe to repeat, the retry is safe. You stop needing to know whether the first attempt landed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pattern, in SQL
&lt;/h3&gt;

&lt;p&gt;The simplest form is to make the write describe the destination rather than the change. This is not idempotent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;Posts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ViewCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ViewCount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- runs twice, counts twice&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;Posts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ViewCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;981&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;-- runs twice, same result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you genuinely need an increment, the idempotency has to come from somewhere else - usually a record of which events you have already counted.&lt;/p&gt;

&lt;p&gt;For inserts, the equivalent is to check before writing. The script that published this post runs as an upsert, so running it a second time edits the post rather than creating a duplicate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;BlogPosts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;BlogPosts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ELSE&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;BlogPosts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a small habit with a large payoff: any script written this way can be re-run after a failure without anyone having to work out how far it got.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But it is only safe when it runs once at a time.&lt;/strong&gt; Run it twice &lt;em&gt;concurrently&lt;/em&gt; and both copies can execute the &lt;code&gt;IF EXISTS&lt;/code&gt;, both see nothing, and both insert. Under SQL Server's default &lt;code&gt;READ COMMITTED&lt;/code&gt; isolation the check and the insert are two separate steps, and nothing stops another session slipping in between them. For a script you run by hand that is rarely a problem. For the same pattern behind an API endpoint, it is exactly what a double submit does.&lt;/p&gt;

&lt;p&gt;Two fixes, and the first one is not optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. Make the database refuse the duplicate. This is the guarantee.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;UX_BlogPosts_Slug&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;BlogPosts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Optionally, serialise the check so the second caller waits instead of failing.&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRAN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;BlogPosts&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UPDLOCK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HOLDLOCK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;BlogPosts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ELSE&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;BlogPosts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UPDLOCK, HOLDLOCK&lt;/code&gt; makes the second session wait at the &lt;code&gt;IF EXISTS&lt;/code&gt; until the first commits, and then see the row. It is a good habit, but it is a habit - forget the hint once and nothing warns you. The unique index cannot be forgotten: the worst case becomes a clean error instead of a silent duplicate.&lt;/p&gt;

&lt;p&gt;On this site the guarantee was there before the script was written. &lt;code&gt;BlogPosts.Slug&lt;/code&gt; has carried a unique index, &lt;code&gt;IX_BlogPosts_Slug&lt;/code&gt;, since the first migration, so two overlapping runs of the publishing script cannot produce two posts - the second stops with error 2601. The script itself has no lock hint, and has never needed one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idempotency keys, and when you need one
&lt;/h3&gt;

&lt;p&gt;Checking before writing works when the row has something naturally unique - a slug, an email, an order reference. When it does not, the client supplies the uniqueness instead: a key generated per intended operation, sent with the request, and stored by the server alongside the result. Stripe popularised the pattern with its &lt;code&gt;Idempotency-Key&lt;/code&gt; header, and it is the right answer for anything that moves money or sends a message.&lt;/p&gt;

&lt;p&gt;The obvious implementation is: look the key up; if it has been seen, return the stored response; if not, do the work and record the key. &lt;strong&gt;That has the same race as the upsert.&lt;/strong&gt; Two requests with the same key - a double click, or a client retrying while the first attempt is still running - can both look the key up, both find nothing, and both do the work. Every request carried the right key, and the work still happened twice.&lt;/p&gt;

&lt;p&gt;The fix is to stop asking the question in code and let the database answer it. Give the key a uniqueness constraint, and make &lt;em&gt;claiming&lt;/em&gt; the key the first write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IdempotencyKeys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ClientId&lt;/span&gt;        &lt;span class="nb"&gt;int&lt;/span&gt;            &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- whose key it is&lt;/span&gt;
    &lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;  &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- as sent by the client&lt;/span&gt;
    &lt;span class="n"&gt;RequestHash&lt;/span&gt;     &lt;span class="nb"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- SHA-256 of the request body&lt;/span&gt;
    &lt;span class="n"&gt;StatusCode&lt;/span&gt;      &lt;span class="nb"&gt;int&lt;/span&gt;            &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;-- NULL while the work is still running&lt;/span&gt;
    &lt;span class="n"&gt;ResponseBody&lt;/span&gt;    &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CreatedAt&lt;/span&gt;       &lt;span class="n"&gt;datetime2&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CompletedAt&lt;/span&gt;     &lt;span class="n"&gt;datetime2&lt;/span&gt;      &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;PK_IdempotencyKeys&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRAN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Claim the key first. A second request with the same key cannot get past this line.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;IdempotencyKeys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RequestHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;requestHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SYSUTCDATETIME&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;-- ... do the database work here: insert the order, reserve the stock ...&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;IdempotencyKeys&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ResponseBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CompletedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SYSUTCDATETIME&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ClientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;IdempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the database arbitrates. When two requests race, the second &lt;code&gt;INSERT&lt;/code&gt; waits on the first one's uncommitted row and then either fails with a duplicate-key error (2627 for a primary key or unique constraint, 2601 for a unique index) or, if the first rolled back, succeeds and does the work itself. There is no window in which both can believe the key is new.&lt;/p&gt;

&lt;p&gt;Two columns in that table are there for reasons that are easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The key is unique per client, not globally.&lt;/strong&gt; Keys come from clients, and two clients can send the same one. With a global key, the second client gets the first client's stored response - someone else's order, returned as theirs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The request hash catches a reused key.&lt;/strong&gt; The same key with a different body is a client bug, not a retry. Replaying the old response would hide it; reject it with a 4xx instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Two ways to hold the claim
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;One transaction, for quick database-only work.&lt;/strong&gt; This is the code above: claim, work and response commit together. A crash anywhere rolls all of it back, claim included, so a retry starts clean. A concurrent duplicate simply waits on the insert and gets the duplicate-key error once the first commits. The cost is that the transaction stays open for the whole operation, so keep it to work that takes milliseconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim, then work, then complete, for long-running work.&lt;/strong&gt; Commit the claim in a short transaction of its own, do the work, then record the response in a second one. Locks stay short, but two new situations appear. A repeat can find a claimed row with &lt;code&gt;CompletedAt&lt;/code&gt; still NULL: answer it with a 409 and a &lt;code&gt;Retry-After&lt;/code&gt;, never by doing the work again. And a crash between claim and completion leaves a row that says "in progress" forever, so give the claim an expiry time and let a later request take over an expired claim - after checking whether the work actually happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  What no database transaction protects
&lt;/h3&gt;

&lt;p&gt;Charging a card is an HTTP call to a payment provider, and rolling back your transaction does not refund it. If the process dies after the charge succeeds and before your commit, the claim is rolled back with everything else, and the retry charges again. The database did its job; the duplicate happened outside it.&lt;/p&gt;

&lt;p&gt;The fix is to carry the idempotency across that boundary. Payment providers take an idempotency key of their own - Stripe accepts one on every &lt;code&gt;POST&lt;/code&gt; - so pass one derived from yours, and the provider refuses the second charge the same way your table refuses the second claim. For emails and webhooks, write the message to an outbox table inside the transaction and send it from there afterwards, so "this message should be sent" commits atomically with the work.&lt;/p&gt;

&lt;p&gt;The remaining details are smaller but each one breaks the pattern if skipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The duplicate-key error is not an error.&lt;/strong&gt; Catch 2627 / 2601, read the stored row, and return its response. Surfacing it as a 500 turns a retry that already succeeded into an apparent failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return the stored response, not a bare 200.&lt;/strong&gt; Otherwise the caller cannot tell what happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys expire.&lt;/strong&gt; Keep them long enough to cover your clients' retry window - Stripe keeps them for at least 24 hours - then delete them in batches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The check in code is still useful - it answers a repeat quickly without opening a transaction. But it is an optimisation. The constraint is the guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the two ideas meet: EF Core's migration history
&lt;/h2&gt;

&lt;p&gt;Entity Framework's &lt;code&gt;__EFMigrationsHistory&lt;/code&gt; table is an idempotency ledger, though nobody calls it that. It exists so that running &lt;code&gt;dotnet ef database update&lt;/code&gt; twice does not apply the same migration twice. Each applied migration's ID is the key, and it is the table's primary key, so the ledger can never hold the same migration twice.&lt;/p&gt;

&lt;p&gt;That protects the ledger, not the schema. Two deploys racing could both run the same &lt;code&gt;ALTER TABLE&lt;/code&gt; before either writes its history row. EF Core 9 closes that gap separately: &lt;code&gt;Migrate()&lt;/code&gt; now takes a database-wide lock before it starts, so only one process migrates at a time. The key stops the bookkeeping duplicating; the lock stops the work duplicating. Same two halves as the idempotency table above.&lt;/p&gt;

&lt;p&gt;Which makes its failure mode instructive - and it happened on this project while this article was being drafted. A migration had been applied under one generated ID. Later the migration file was regenerated and picked up a new timestamp in its name. The change was already in the database; the ledger held the &lt;em&gt;old&lt;/em&gt; key. EF compared files to history, saw an ID it had no record of, and tried to apply it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Column names in each table must be unique.
Column name 'ResumeUpdatedAt' in table 'SiteSettings' is specified more than once.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema was correct. Only the bookkeeping was wrong. The fix is to record the key that should have been there - itself written idempotently, so re-running it is harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;__EFMigrationsHistory&lt;/span&gt;
               &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;MigrationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'20260920154332_AddResumeToSiteSettings'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;__EFMigrationsHistory&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MigrationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProductVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'20260920154332_AddResumeToSiteSettings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'9.0.0'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the check-then-insert race is harmless: &lt;code&gt;MigrationId&lt;/code&gt; is the primary key, so a concurrent second run would fail with a duplicate-key error rather than write a second row.&lt;/p&gt;

&lt;p&gt;The lesson generalises past EF. An idempotency mechanism is only as good as the stability of its key. Change how the key is derived and the ledger stops protecting you - silently, until something tries to run twice. The mirror-image failure, where EF tries to create something the database already has, is covered in &lt;a href="https://knowledgemarkg.com/blog/ef-core-there-is-already-an-object-named-in-the-database" rel="noopener noreferrer"&gt;"There is already an object named X in the database": what EF Core is really telling you&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;p&gt;Indexes answer a question about reading: can the database find these rows without looking at all of them? Get the column order right and most of the work disappears; get it backwards and the database reads past rows it was supposed to skip, while you still pay for the index on every insert.&lt;/p&gt;

&lt;p&gt;Idempotency answers a question about writing: if this runs twice, is the result the same? Make it so - including for the side effects that leave your database - and the entire category of bug that begins "the request timed out and then everything was duplicated" stops existing.&lt;/p&gt;

&lt;p&gt;And the two meet in one place: a unique index. It is how the database finds a key quickly, and it is the only thing that keeps "have I seen this key?" true when two requests ask at the same moment. A check in your code is a hope; a constraint is a guarantee.&lt;/p&gt;

&lt;p&gt;Neither is advanced. Both are the difference between an application that works in testing and one that works in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a database index, in simple terms?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A second, ordered copy of some of your columns with pointers back to the rows. It gives the query planner a faster route to the data without changing the table itself. The cost is that every insert and update has to maintain it as well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does column order matter in a composite index?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A composite index is sorted by its first column, then by its second within that - like a phone book by surname then first name. An index on (IsPublished, PublishedAt) lets a query seek straight to the published rows and read them already in date order. Reverse the columns and the database can no longer seek; it has to read past every unpublished row to find the published ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I not add an index?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On small tables, on a column with very few distinct values on its own, on an index whose columns are the leading columns of another index, and on anything you have not seen in a slow query. Every index is paid for on every write, so one nobody reads is a pure tax. Add them from an execution plan, not from intuition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does idempotent mean?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Doing the operation twice leaves the same result as doing it once. Setting a value is idempotent; incrementing one is not. It matters because a network cannot tell you whether a request failed or the response was simply lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What problem do idempotency keys actually solve?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They remove the need to know whether a timed-out request landed. The client sends a key with the operation; the server stores it with the result. A repeat of the same key returns the stored response instead of doing the work again. For work outside your database, such as a card charge, pass a key on to the provider as well, because rolling back your transaction does not undo it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is checking for the key before doing the work enough?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Two requests with the same key that arrive together can both see 'not seen' before either writes, and both do the work. Put a UNIQUE constraint on the key (scoped per client) and insert the key as the first step of the transaction, so the second request fails on that insert. Treat that duplicate-key error (2627 or 2601 in SQL Server) as 'already done' and return the stored response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I make an SQL script safe to re-run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Write the destination rather than the change, and check before inserting. An upsert - IF EXISTS then UPDATE, ELSE INSERT - can be run any number of times without creating duplicates, as long as the runs do not overlap. If they can, back it with a unique index on the natural key (and optionally WITH (UPDLOCK, HOLDLOCK) on the check), so concurrent runs cannot both insert.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/database-indexes-and-idempotency" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>dotnet</category>
      <category>backend</category>
    </item>
    <item>
      <title>ASP.NET Core Web API Performance: From 900ms to 3ms on Cheap Hosting</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:50:45 +0000</pubDate>
      <link>https://dev.to/kausarraza/aspnet-core-web-api-performance-from-900ms-to-3ms-on-cheap-hosting-3ad0</link>
      <guid>https://dev.to/kausarraza/aspnet-core-web-api-performance-from-900ms-to-3ms-on-cheap-hosting-3ad0</guid>
      <description>&lt;p&gt;The ASP.NET Core Web API behind this site runs on a budget setup: an ASP.NET Core 9 app on one box, and SQL Server on shared hosting somewhere else entirely. Every query crosses a network I do not control. A cold read of the homepage payload took somewhere between &lt;strong&gt;200 and 1000 milliseconds&lt;/strong&gt;, and it was not the query plan that was slow. It was the distance.&lt;/p&gt;

&lt;p&gt;Warm reads now come back in &lt;strong&gt;2 to 3 milliseconds&lt;/strong&gt;. Nothing was rewritten in C++ and nothing moved to Redis. Seven changes did it, and every one of them is available to you on the cheapest hosting you can find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint worth naming first
&lt;/h2&gt;

&lt;p&gt;Most ASP.NET Core Web API performance advice quietly assumes your database is in the same data centre as your app. On managed cloud that is true. On the shared hosting most small projects actually run on, it is not.&lt;/p&gt;

&lt;p&gt;When the database is 200 milliseconds away, the winning move stops being "make the query faster" and becomes &lt;strong&gt;"make fewer round trips, and stop repeating the ones you have already made"&lt;/strong&gt;. Almost everything below follows from that one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Project into DTOs. Never load entities
&lt;/h2&gt;

&lt;p&gt;The single biggest change in the whole exercise. Every read path in the Web API goes straight from &lt;code&gt;IQueryable&lt;/code&gt; into a DTO, so EF Core emits one flat SELECT with only the columns that will actually be serialised:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PostSummaryDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ToSummary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PostSummaryDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Excerpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PublishedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TermDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Slug&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this avoids matters more than what it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;Include&lt;/code&gt; fan-out.&lt;/strong&gt; Loading a post with its categories and tags as entities pulls every column of every row, including the full article body you were not going to display in a list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No change tracking.&lt;/strong&gt; Projections into a non-entity type skip the tracker entirely - there is nothing to snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No lazy-load surprises.&lt;/strong&gt; A missing &lt;code&gt;Include&lt;/code&gt; cannot turn into an N+1 later, because there is no navigation to walk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Storing these as &lt;code&gt;Expression&lt;/code&gt; fields rather than methods keeps them composable: they sit in one file, and every endpoint reuses the same shape. When the DTO gains a field, one edit covers the whole API.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pool the context, and default to no tracking
&lt;/h2&gt;

&lt;p&gt;Two lines, applied once, at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddDbContextPool&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApplicationDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSqlServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnableRetryOnFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxRetryCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxRetryDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommandTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseQueryTrackingBehavior&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QueryTrackingBehavior&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoTracking&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;poolSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;128&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddDbContextPool&lt;/code&gt; reuses context instances instead of building a new one per request, so the model and DI wiring are not re-resolved thousands of times a minute. &lt;code&gt;NoTracking&lt;/code&gt; as the default is safe precisely because of the previous section - nothing here needs a change tracker, and the handful of write paths ask for tracking explicitly with &lt;code&gt;AsTracking()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;EnableRetryOnFailure&lt;/code&gt; is not optional on shared hosting.&lt;/strong&gt; Remote database connections drop. Without retries that is a 500 for the visitor; with them it is a hiccup nobody sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Output caching, with tags
&lt;/h2&gt;

&lt;p&gt;This is where the 3 milliseconds come from. Response caching is usually skipped because of the obvious objection: &lt;em&gt;if I cache for five minutes, my edits take five minutes to appear.&lt;/em&gt; Tag-based eviction removes that objection completely.&lt;/p&gt;

&lt;p&gt;Register a policy per content type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"posts-policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"posts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetVaryByQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pageSize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sort"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then have every write throw away exactly the tags it touched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cacheStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EvictByTagAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"posts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cacheStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EvictByTagAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"taxonomy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is a cache with no staleness cost. Publishing a post evicts the post tags and the change is live immediately, while a tag nobody edited keeps serving from memory. A warm read never touches the database at all, which is why the network distance stops mattering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get &lt;code&gt;SetVaryByQuery&lt;/code&gt; right or the cache becomes a bug.&lt;/strong&gt; Every query parameter that changes the response has to be listed. Miss one - an &lt;code&gt;author&lt;/code&gt; filter, say - and two different requests share one cached body, which is a data leak dressed up as a performance win.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One aggregated endpoint instead of five round trips
&lt;/h2&gt;

&lt;p&gt;The homepage needs featured posts, latest posts, popular posts, categories and tags. Five endpoints is the tidy design, and on a remote database it is also five times the latency.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/site/home&lt;/code&gt; returns all five in one response. The client makes one request, the server makes one trip. This is not clever, it is just counting - and on a slow link, counting round trips beats micro-optimising any one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Never make a reader wait on a write
&lt;/h2&gt;

&lt;p&gt;Every article view should increment a counter. Doing that inline means the reader waits for a write to a database on the other side of the country, in order to read something that was already rendered.&lt;/p&gt;

&lt;p&gt;Instead the view goes onto a bounded channel and a background worker batches them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostViewEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_channel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateBounded&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostViewEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BoundedChannelOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;FullMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BoundedChannelFullMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DropWrite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;SingleReader&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hosted service drains it every five seconds, up to 200 events at a time, and writes one batch. Two details are deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bounded, not unbounded.&lt;/strong&gt; An unbounded channel under a traffic spike is a memory leak with a queue in front of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DropWrite&lt;/code&gt; when full.&lt;/strong&gt; If the choice is between losing a few analytics rows and making real visitors wait, the analytics lose. Say that out loud in a comment so the next person does not "fix" it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. The split-query decision, and why the default was wrong here
&lt;/h2&gt;

&lt;p&gt;Post projections pull two collections - categories and tags. EF Core warns about this and suggests &lt;code&gt;AsSplitQuery()&lt;/code&gt;, which avoids the cartesian join by issuing separate queries.&lt;/p&gt;

&lt;p&gt;On this setup that advice is backwards. A split query trades duplicated rows for &lt;strong&gt;extra round trips&lt;/strong&gt;, and round trips are the expensive thing here. With a handful of terms per post, the duplicated rows cost less than another 200 millisecond hop. So the behaviour is set explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseQuerySplittingBehavior&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QuerySplittingBehavior&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SingleQuery&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stating it explicitly also silences the warning honestly - the setting is a decision, not an oversight. If the database were local, or if a post had hundreds of tags, the right answer would flip. &lt;strong&gt;Measure your own link before copying either choice.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Compression, and a cache in front of the cache
&lt;/h2&gt;

&lt;p&gt;Brotli and Gzip on JSON responses, at the &lt;em&gt;fastest&lt;/em&gt; compression level rather than the smallest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BrotliCompressionProviderOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CompressionLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fastest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maximum compression spends CPU to save bytes that were not the bottleneck. Fastest gets most of the size reduction for a fraction of the cost.&lt;/p&gt;

&lt;p&gt;In front of all of it, the Next.js frontend keeps its own data cache, so a page that has not changed does not even ask the API. Two cache layers sound excessive until you notice they fail independently - if one is cold, the other still absorbs the hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not bother with
&lt;/h2&gt;

&lt;p&gt;Honesty is more useful than a longer list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redis.&lt;/strong&gt; A second service to run, pay for and monitor, to solve a problem in-memory output caching already solved on one box. Worth it when you scale past one instance. Not before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compiled queries.&lt;/strong&gt; Real, measurable, and completely lost in the noise next to a 200 millisecond network hop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand-written SQL.&lt;/strong&gt; The projections already generate the query I would have written. Dapper would win microseconds and cost me the migrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More indexes, at first.&lt;/strong&gt; Indexes fix slow queries. My queries were not slow - my network was. Adding indexes before measuring is guessing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to find out where your own time goes
&lt;/h2&gt;

&lt;p&gt;Before changing anything, get the number. Turn on EF Core command logging in development and read what it actually emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnableSensitiveDataLogging&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask three questions in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How many queries per request?&lt;/strong&gt; If a list endpoint issues one query per row, stop here and fix the N+1. Nothing else matters yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How wide are they?&lt;/strong&gt; A &lt;code&gt;SELECT&lt;/code&gt; naming forty columns to render a card is a missing projection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How far away is the database?&lt;/strong&gt; Time a trivial query - &lt;code&gt;SELECT 1&lt;/code&gt;. That figure is the floor under every request you will ever serve, and it decides whether you should be optimising queries or eliminating them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last number is the one that reframed this whole exercise. Once I knew the floor was 200 milliseconds, caching stopped being an optimisation and became the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth knowing before you copy any of it
&lt;/h2&gt;

&lt;p&gt;These choices suit a read-heavy Web API on a slow link. A write-heavy internal tool on a local database would want almost the opposite: tracking on, caching off, split queries, real indexes.&lt;/p&gt;

&lt;p&gt;The transferable part is not the settings. It is measuring the floor first, then deciding whether your problem is &lt;em&gt;slow work&lt;/em&gt; or &lt;em&gt;repeated work&lt;/em&gt;. Mine was repeated work, and repeated work is the cheap kind to fix.&lt;/p&gt;

&lt;p&gt;If you are deciding where to run any of this, &lt;a href="https://knowledgemarkg.com/blog/windows-vps-vs-linux-vps-aspnet-core" rel="noopener noreferrer"&gt;ASP.NET Core does not need Windows hosting&lt;/a&gt; - and running SQL Server in &lt;a href="https://knowledgemarkg.com/blog/docker-tutorial-complete-guide-beginner-to-advanced-2025" rel="noopener noreferrer"&gt;a Docker container&lt;/a&gt; keeps the whole stack on one cheap Linux box. If you are still assembling the fundamentals, the &lt;a href="https://knowledgemarkg.com/blog/full-stack-web-developer-roadmap-2025-complete-guide" rel="noopener noreferrer"&gt;full stack developer roadmap&lt;/a&gt; covers the ground around this.&lt;/p&gt;

&lt;p&gt;And if you would rather someone else did it, &lt;a href="https://dev.to/hire"&gt;this is what I do&lt;/a&gt; - send me the slow endpoint and I will tell you which of these seven it needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I make an ASP.NET Core Web API faster?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Start by measuring where the time goes. In most content APIs the wins in order are: project queries into DTOs instead of loading entities, enable output caching with tags so repeat reads never reach the database, pool the DbContext with no-tracking as the default, and move writes such as view counters off the request thread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is an EF Core projection and why is it faster?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A projection selects straight from IQueryable into a DTO, so EF generates SQL that returns only the columns you use. It avoids Include fan-out, skips change tracking entirely, and removes the chance of an N+1 later because there are no navigations to lazy load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does output caching make my API serve stale data?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not if you use tags. Give each policy a tag, then have every write evict the tags it affects. A published post clears the post tags immediately, so edits appear at once while untouched data keeps serving from memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use AsSplitQuery in EF Core?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It depends on where your database is. Split queries avoid a cartesian join but cost extra round trips. If the database is remote and each hop costs 200ms, a single query with some duplicated rows is faster. If the database is local and the collections are large, split queries win. Measure the round trip before choosing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does AddDbContextPool actually do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It reuses DbContext instances between requests instead of constructing a new one each time, so EF does not re-resolve the model and dependency graph on every call. It is a one-line change and is safe as long as you do not keep per-request state on the context itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Redis to cache an ASP.NET Core API?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not on a single instance. The built-in output cache lives in memory and needs no extra service to run, pay for or monitor. Redis becomes worth it once you scale to more than one instance and need the cache shared between them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my API slow even though my queries are fast?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually network distance. Time a trivial query such as SELECT 1 against your production database - that figure is the floor under every request. If it is 200ms, no amount of query tuning gets you below it, and the answer is to make fewer round trips and cache the ones you have made.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop page view counting from slowing down requests?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Write it asynchronously. Push the event onto a bounded channel and let a background hosted service flush batches every few seconds. Use a bounded channel that drops writes when full, so a traffic spike costs you analytics rows rather than memory or response time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/aspnet-core-web-api-performance" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
