<?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: Aleksei Aleinikov</title>
    <description>The latest articles on DEV Community by Aleksei Aleinikov (@aleksei_aleinikov).</description>
    <link>https://dev.to/aleksei_aleinikov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2950325%2F67548842-b25f-4c39-84ef-bc470e23aa8d.jpg</url>
      <title>DEV Community: Aleksei Aleinikov</title>
      <link>https://dev.to/aleksei_aleinikov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aleksei_aleinikov"/>
    <language>en</language>
    <item>
      <title>JWT Explained: What a JSON Web Token Is and How It Breaks</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/jwt-explained-what-a-json-web-token-is-and-how-it-breaks-1d74</link>
      <guid>https://dev.to/aleksei_aleinikov/jwt-explained-what-a-json-web-token-is-and-how-it-breaks-1d74</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fro1gn2inxt4tv9pdisb1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fro1gn2inxt4tv9pdisb1.webp" alt="JWT Explained: What a JSON Web Token Is and How It Breaks" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Almost every login you use today ends with a &lt;strong&gt;JSON Web Token&lt;/strong&gt;. The identity provider hands one to your app, the app sends it with every API call, and the API trusts it without asking anyone. That last part is the whole point of a JWT, and also the reason a small mistake in how it is checked turns into “anyone can be admin”.&lt;/p&gt;

&lt;p&gt;This guide explains what a JWT is, part by part, how a server should verify one, which signing algorithm to pick, and the attacks that still work against real systems. Every example token in it can be pasted into the free &lt;a href="https://www.alekseialeinikov.com/en/tools/jwt-analyzer" rel="noopener noreferrer"&gt;JWT decoder and security analyzer&lt;/a&gt;, which runs entirely in your browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fro1gn2inxt4tv9pdisb1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fro1gn2inxt4tv9pdisb1.webp" alt="JWT explained: a JSON Web Token works like a boarding pass. Anyone can read it, whoever holds it gets in, and only the issuer can print the seal; a pass with alg none is void." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a JWT? A JSON Web Token example
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A JWT (JSON Web Token, pronounced “jot”) is a compact, URL-safe string that carries a set of claims and a signature over them.&lt;/strong&gt; It is defined in RFC 7519; the signing and encryption formats underneath it are JWS (RFC 7515) and JWE (RFC 7516).&lt;/p&gt;

&lt;p&gt;A signed JWT has three parts separated by dots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImVkaXRvciIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.92XVsnsq7eHmUoHn1v4cCfyufbIv5l0Pm8L_Ec6PDH8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Decode the first two parts from base64url and you get two ordinary JSON objects:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;header&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;em&gt;payload&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1767225600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1767226500&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;The third part is the &lt;strong&gt;signature&lt;/strong&gt; : 32 bytes of HMAC-SHA256 over &lt;code&gt;header.payload&lt;/code&gt;, computed with a secret only the issuer knows. Change one character of the payload, for example &lt;code&gt;"role": "admin"&lt;/code&gt;, and the signature no longer matches.&lt;/p&gt;

&lt;p&gt;That is all a JWT is: claims plus proof of who wrote them. The interesting part is everything around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a JSON Web Token
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38ewj79o7mxrwa50k8w9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38ewj79o7mxrwa50k8w9.webp" alt="JWT structure: header, payload and signature are base64url-encoded and joined by dots; the signature is computed over the encoded header and payload with the issuer’s key." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Two readable JSON objects and one signature that binds them to a key.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The header
&lt;/h3&gt;

&lt;p&gt;The header says how to verify the token. The fields that matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signing algorithm: &lt;code&gt;HS256&lt;/code&gt;, &lt;code&gt;RS256&lt;/code&gt;, &lt;code&gt;ES256&lt;/code&gt;, &lt;code&gt;EdDSA&lt;/code&gt;…&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;none&lt;/code&gt;, or an algorithm your server never issues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;typ&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Token type: &lt;code&gt;JWT&lt;/code&gt;, or &lt;code&gt;at+jwt&lt;/code&gt; for OAuth access tokens (RFC 9068)&lt;/td&gt;
&lt;td&gt;Missing type makes token confusion easier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key ID: which key in the issuer’s key set signed it&lt;/td&gt;
&lt;td&gt;Treated as a file path or SQL value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jku&lt;/code&gt;, &lt;code&gt;x5u&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;URL of a key set or certificate&lt;/td&gt;
&lt;td&gt;Fetched blindly by the verifier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jwk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A public key embedded in the token itself&lt;/td&gt;
&lt;td&gt;Trusted as the verification key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  The payload: claims
&lt;/h3&gt;

&lt;p&gt;The payload is a JSON object of &lt;strong&gt;claims&lt;/strong&gt;. RFC 7519 registers seven of them; everything else is up to the issuer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;What the verifier does with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iss&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Issuer&lt;/td&gt;
&lt;td&gt;Must match the identity provider you trust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subject&lt;/td&gt;
&lt;td&gt;The user or client the token is about&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Audience&lt;/td&gt;
&lt;td&gt;Must contain &lt;em&gt;your&lt;/em&gt; API’s identifier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expiration time&lt;/td&gt;
&lt;td&gt;Reject after this moment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nbf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not before&lt;/td&gt;
&lt;td&gt;Reject before this moment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Issued at&lt;/td&gt;
&lt;td&gt;Age checks, debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jti&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JWT ID&lt;/td&gt;
&lt;td&gt;Unique ID for one-time use or revocation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Times are &lt;strong&gt;NumericDate&lt;/strong&gt; : whole seconds since 1970-01-01 UTC. A surprising number of bugs come from writing milliseconds (&lt;code&gt;Date.now()&lt;/code&gt; in JavaScript) instead: the token then expires tens of thousands of years from now.&lt;/p&gt;
&lt;h3&gt;
  
  
  The signature
&lt;/h3&gt;

&lt;p&gt;For HS256 the signature is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;For RS256, ES256 or EdDSA it is a public-key signature over the same input. Either way it covers the &lt;em&gt;encoded&lt;/em&gt; header and payload, so the server verifies exactly the bytes it received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signed is not encrypted
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Base64url is an encoding, not encryption.&lt;/strong&gt; Anyone holding a JWT can read every claim in it, no key needed:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Terminal window&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'console.log(JSON.parse(Buffer.from(process.argv[1].split(".")[1], "base64url")))'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;This is the most common misunderstanding about JWTs, and it has consequences. Tokens end up in browser storage, logs, analytics, crash reports and proxy caches. Whatever you put in the payload, you are publishing to all of them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A JWT is a boarding pass. Anyone who sees it can read the name on it, whoever holds it gets on board, and only the issuer can print the seal that makes it valid.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So keep the payload to identifiers and permissions. No passwords, API keys, card numbers or personal data beyond what the API needs. If the content really must be confidential, use an encrypted JWT ( &lt;strong&gt;JWE&lt;/strong&gt; , five dot-separated parts instead of three), or keep the data on the server and put an opaque ID in the token.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify a JWT correctly
&lt;/h2&gt;

&lt;p&gt;Decoding tells you what a token &lt;em&gt;claims&lt;/em&gt;. Verifying tells you whether to believe it. Verification is a checklist, and the order matters: each step closes a specific attack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn2wqit2q2oc0365l6na6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn2wqit2q2oc0365l6na6.webp" alt="JWT verification checklist: limit size, pin the algorithm, choose the key from your own key set, check the signature, then exp and nbf, then iss, aud and typ. Each step blocks a named attack." width="799" height="481"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Most JWT breaches skip one of these steps; none of them break the cryptography.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bound the input.&lt;/strong&gt; Reject tokens above a sane size before parsing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the algorithm.&lt;/strong&gt; Accept only the algorithms you actually issue, configured on the server. Never let the token’s &lt;code&gt;alg&lt;/code&gt; choose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the key yourself.&lt;/strong&gt; Look the &lt;code&gt;kid&lt;/code&gt; up in &lt;em&gt;your&lt;/em&gt; key set (a JWKS from your identity provider). Ignore &lt;code&gt;jwk&lt;/code&gt;, &lt;code&gt;jku&lt;/code&gt; and &lt;code&gt;x5u&lt;/code&gt; from the token.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check the signature.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check time:&lt;/strong&gt; &lt;code&gt;exp&lt;/code&gt; and &lt;code&gt;nbf&lt;/code&gt;, with a small clock tolerance (30–60 seconds), and require &lt;code&gt;exp&lt;/code&gt; to be present.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check context:&lt;/strong&gt; &lt;code&gt;iss&lt;/code&gt; is your identity provider, &lt;code&gt;aud&lt;/code&gt; contains your API, &lt;code&gt;typ&lt;/code&gt; is what this endpoint expects.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With a maintained library this is a few lines. In Node.js with &lt;code&gt;jose&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;verify.js&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRemoteJWKSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jwtVerify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jose&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRemoteJWKSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://auth.example.com/.well-known/jwks.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://auth.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="na"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;at+jwt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="na"&gt;clockTolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;And in Python with PyJWT:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;verify.py&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PyJWKClient&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://auth.example.com/.well-known/jwks.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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_signing_key_from_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;

&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;algorithms&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;ES256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;leeway&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;options&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;require&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&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;iat&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;iss&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;aud&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;sub&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;The highlighted lines are the security. Delete any one of them and the code still “works” in every test, which is exactly why they get deleted.&lt;/p&gt;

&lt;p&gt;Warning&lt;/p&gt;

&lt;p&gt;Never call a decode function to read claims and make an authorization decision on them. Functions named &lt;code&gt;decode&lt;/code&gt; in many libraries skip the signature check by design or by flag. Only claims returned by a successful &lt;em&gt;verify&lt;/em&gt; are facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  HS256 vs RS256 vs ES256: which algorithm?
&lt;/h2&gt;

&lt;p&gt;The real question is not speed or key length. It is &lt;strong&gt;who can mint tokens&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzhmu1ewu1n4p1c8ki6ki.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzhmu1ewu1n4p1c8ki6ki.webp" alt="HS256 vs RS256 and ES256: with HS256 every service that verifies tokens holds the shared secret and could forge them; with RS256 or ES256 only the issuer holds the private key and services verify with the public JWKS." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;With a shared secret, every verifier is also a potential issuer.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Signature size&lt;/th&gt;
&lt;th&gt;Use it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HS256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HMAC, shared secret&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;One service issues &lt;em&gt;and&lt;/em&gt; verifies its own tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RS256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RSA PKCS#1 v1.5&lt;/td&gt;
&lt;td&gt;256 bytes (2048-bit key)&lt;/td&gt;
&lt;td&gt;Broadest library support; most identity providers default to it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PS256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RSA-PSS&lt;/td&gt;
&lt;td&gt;256 bytes (2048-bit key)&lt;/td&gt;
&lt;td&gt;RSA, with the more modern padding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ES256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ECDSA P-256&lt;/td&gt;
&lt;td&gt;64 bytes&lt;/td&gt;
&lt;td&gt;Small tokens, wide support; a good default for new systems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;EdDSA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ed25519&lt;/td&gt;
&lt;td&gt;64 bytes&lt;/td&gt;
&lt;td&gt;Simplest safe signatures, where your libraries support it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With HS256 the verification key &lt;em&gt;is&lt;/em&gt; the signing key. If five microservices verify tokens, five services hold a secret that lets them create a token for any user, and a leak from the least protected one compromises all of them. With an asymmetric algorithm only the identity provider holds the private key; services fetch the public keys from a JWKS URL, and you can rotate keys by publishing a new &lt;code&gt;kid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you do use HS256, the secret must be random and long: RFC 7518 requires a key at least as long as the hash output (256 bits for HS256), and RFC 8725 says human-memorable passwords must not be used as HMAC keys.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Terminal window&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;h2&gt;
  
  
  Six JWT attacks that still work
&lt;/h2&gt;

&lt;p&gt;None of these break HMAC, RSA or ECDSA. They exploit the verifier: code that trusts the token to explain how it should be trusted. Tim McLean’s 2015 write-up of critical vulnerabilities in JWT libraries made the first two famous, and they are still found in custom code and old dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;alg: none&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;JWS defines an “unsecured” algorithm called &lt;code&gt;none&lt;/code&gt; with an empty signature. An attacker takes a valid token, changes the payload, sets &lt;code&gt;"alg": "none"&lt;/code&gt;, removes the signature and keeps the trailing dot. A verifier that honours the header accepts it. Variants such as &lt;code&gt;None&lt;/code&gt; or &lt;code&gt;nOnE&lt;/code&gt; slip past naive string checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; an explicit algorithm allow-list on the server, as in the code above.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Algorithm confusion (RS256 to HS256)
&lt;/h3&gt;

&lt;p&gt;The server expects RS256 and verifies with the issuer’s &lt;em&gt;public&lt;/em&gt; key. The attacker changes &lt;code&gt;alg&lt;/code&gt; to &lt;code&gt;HS256&lt;/code&gt; and signs the token with HMAC, using that public key, which is public, as the secret. A library that picks the algorithm from the header now runs HMAC with the same key bytes, and the forged token verifies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; pin the algorithm, and use libraries that bind a key to one algorithm type.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Weak HMAC secrets
&lt;/h3&gt;

&lt;p&gt;An HS256 token is an offline puzzle: anyone with one token can test candidate secrets without touching your server. &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;changeme&lt;/code&gt;, the framework’s sample value, or &lt;code&gt;your-256-bit-secret&lt;/code&gt; copied from a tutorial fall in seconds; hashcat even has a dedicated mode for JWTs (16500).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; a random 256-bit secret from a secret manager, or move to an asymmetric algorithm.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Key injection through the header
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;jwk&lt;/code&gt; embeds a public key in the token, and &lt;code&gt;jku&lt;/code&gt; or &lt;code&gt;x5u&lt;/code&gt; point to a URL where the key can be found. A verifier that uses them lets the attacker sign with their own key and supply the matching public key. &lt;code&gt;kid&lt;/code&gt; is subtler: it is just a string, and code that uses it as a file path (&lt;code&gt;../../dev/null&lt;/code&gt;) or inside a SQL query turns it into path traversal or injection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; take keys only from your own configured key set; treat &lt;code&gt;kid&lt;/code&gt; as an opaque lookup key. RFC 8725 calls this out explicitly: do not trust received claims.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Missing audience and issuer checks
&lt;/h3&gt;

&lt;p&gt;Your company has two APIs that trust the same identity provider. A token issued for the low-risk one is replayed against the payments API, and it verifies, because the signature and expiry are fine. Nobody checked &lt;code&gt;aud&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; every API checks that &lt;code&gt;aud&lt;/code&gt; names it and that &lt;code&gt;iss&lt;/code&gt; is the expected issuer. Use explicit &lt;code&gt;typ&lt;/code&gt; values so an ID token cannot be used as an access token.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Tokens that live forever
&lt;/h3&gt;

&lt;p&gt;A token without &lt;code&gt;exp&lt;/code&gt;, or with a lifetime of months, is a long-lived password that cannot be changed. A stolen one keeps working after the user logs out, changes their password or leaves the company.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; access tokens measured in minutes, &lt;code&gt;exp&lt;/code&gt; required by the verifier, and rotating refresh tokens that the server can revoke.&lt;/p&gt;

&lt;p&gt;Library bugs exist too: in 2022 Java’s ECDSA implementation (versions 15 to 18) accepted an all-zero signature as valid for any message, which made every ES256 token forgeable on unpatched runtimes (CVE-2022-21449, “Psychic Signatures”). Keeping crypto runtimes and JWT libraries patched is part of the checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  JWT decoder: read a token without leaking it
&lt;/h2&gt;

&lt;p&gt;A JWT is a &lt;strong&gt;bearer credential&lt;/strong&gt; : whoever holds an unexpired production token can usually call your API as that user. Pasting one into a random website that uploads it to a server is handing it over.&lt;/p&gt;

&lt;p&gt;Three safe options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Command line:&lt;/strong&gt; the Node.js one-liner above, or your language’s equivalent. Nothing leaves the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A local browser decoder:&lt;/strong&gt; the &lt;a href="https://www.alekseialeinikov.com/en/tools/jwt-analyzer" rel="noopener noreferrer"&gt;JWT decoder and security analyzer&lt;/a&gt; on this site decodes, verifies and audits tokens with Web Crypto in the page itself; tokens, secrets and keys are never uploaded, stored or put into the URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expired or test tokens&lt;/strong&gt; , when you only need to see the structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is what the analyzer reports for a token signed with &lt;code&gt;your-256-bit-secret&lt;/code&gt;, the sample secret that circulates in countless tutorials:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vwk5kkx0tvvyeqkjcb2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vwk5kkx0tvvyeqkjcb2.webp" alt="JWT decoder and security analyzer showing grade F for an HS256 token: signed with a well-known secret and valid for four years, with the decoded header and payload and a fix for each finding." width="800" height="1097"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The analyzer tries more than 100 well-known secrets locally and explains every finding with a fix.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It flags the forgeable signature first, then the multi-year lifetime and the missing &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;aud&lt;/code&gt;, and gives a fix for each. Paste the issuer’s public key or JWKS and it verifies RS256, PS256, ES256 and EdDSA signatures as well. If you work with AI agents, the same analysis is exposed to them as a WebMCP tool, &lt;code&gt;analyze_jwt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  JWT vs session cookies: when to use a JWT
&lt;/h2&gt;

&lt;p&gt;JWTs are popular enough that they get used where a session would be simpler. An honest comparison:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Server-side session&lt;/th&gt;
&lt;th&gt;JWT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stored on the server, cookie holds an ID&lt;/td&gt;
&lt;td&gt;Self-contained, verified with a key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Revocation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Instant: delete the session&lt;/td&gt;
&lt;td&gt;Waits for &lt;code&gt;exp&lt;/code&gt;, unless you add server state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scaling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Needs a shared session store&lt;/td&gt;
&lt;td&gt;Any service with the public key can verify&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Across services and domains&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Awkward&lt;/td&gt;
&lt;td&gt;What it was designed for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Size per request&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A few dozen bytes&lt;/td&gt;
&lt;td&gt;Hundreds of bytes to several KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use a &lt;strong&gt;session&lt;/strong&gt; for a classic web app with one backend. Use &lt;strong&gt;JWTs&lt;/strong&gt; when several services, an API gateway or third parties must verify identity without a shared store, and wherever OAuth 2.0 and OpenID Connect already hand you one: ID tokens are always JWTs, and many access tokens are.&lt;/p&gt;

&lt;h3&gt;
  
  
  JWT vs OAuth
&lt;/h3&gt;

&lt;p&gt;They are not alternatives. &lt;strong&gt;OAuth 2.0 is a protocol&lt;/strong&gt; for getting an access token on a user’s behalf; &lt;strong&gt;a JWT is a token format&lt;/strong&gt;. An OAuth access token can be a JWT (RFC 9068 standardises that profile, with &lt;code&gt;typ: at+jwt&lt;/code&gt;) or an opaque random string the API checks with the authorization server. OpenID Connect, the identity layer on top of OAuth, always uses a JWT for the ID token.&lt;/p&gt;

&lt;p&gt;The two combine well. For browser apps, the safest place for a JWT is an HttpOnly cookie, not &lt;code&gt;localStorage&lt;/code&gt;; the details, including CSRF protection, are in &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/security/stop-storing-jwts-in-localstorage-cookie-auth-for-spas-2026" rel="noopener noreferrer"&gt;why you should stop storing JWTs in localStorage&lt;/a&gt;. If you run your own identity provider, &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/security/is-keycloak-free-keycloak-vs-authentik-2026" rel="noopener noreferrer"&gt;Keycloak or Authentik&lt;/a&gt; will issue and sign the tokens for you, and a stronger login in front of them, such as &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/security/passkeys-vs-passwords-webauthn-how-it-works-2026" rel="noopener noreferrer"&gt;passkeys&lt;/a&gt;, protects the moment a token is created.&lt;/p&gt;

&lt;p&gt;The HMAC behind HS256 is the same primitive that signs webhooks; &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/webhooks-explained-signatures-retries-idempotency" rel="noopener noreferrer"&gt;webhook signatures&lt;/a&gt; face the same rules about secret strength and constant-time comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;A JWT is a readable JSON document with a signature. The format is simple and the cryptography is sound; what fails in practice is the code around it. Pin the algorithm, choose the key yourself, require &lt;code&gt;exp&lt;/code&gt;, check &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;aud&lt;/code&gt;, keep tokens short-lived and keep secrets out of the payload. Do that with a maintained library, and a JWT is one of the most convenient credentials you can hand out.&lt;/p&gt;

&lt;p&gt;And the next time you need to see what is inside one, decode it locally, not in a page that uploads it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/security/jwt-explained-json-web-token" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>jwt</category>
      <category>jsonwebtoken</category>
      <category>authentication</category>
      <category>oauth</category>
    </item>
    <item>
      <title>JWT erklärt: Was ein JSON Web Token ist und wie es bricht</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/jwt-erklart-was-ein-json-web-token-ist-und-wie-es-bricht-59ko</link>
      <guid>https://dev.to/aleksei_aleinikov/jwt-erklart-was-ein-json-web-token-ist-und-wie-es-bricht-59ko</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fro1gn2inxt4tv9pdisb1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fro1gn2inxt4tv9pdisb1.webp" alt="JWT erklärt: Was ein JSON Web Token ist und wie es bricht" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fast jeder Login, den Sie heute nutzen, endet mit einem &lt;strong&gt;JSON Web Token&lt;/strong&gt;. Der Identity Provider gibt Ihrer App eines, die App schickt es bei jedem API-Aufruf mit, und die API vertraut ihm, ohne jemanden zu fragen. Genau das ist der Sinn eines JWT, und genau deshalb wird aus einem kleinen Fehler bei der Prüfung schnell „jeder kann Admin sein”.&lt;/p&gt;

&lt;p&gt;Dieser Leitfaden erklärt, was ein JWT ist, Teil für Teil, wie ein Server es prüfen sollte, welcher Signaturalgorithmus passt und welche Angriffe auf reale Systeme heute noch funktionieren. Jedes Beispiel-Token lässt sich in den kostenlosen &lt;a href="https://www.alekseialeinikov.com/de/tools/jwt-analyzer" rel="noopener noreferrer"&gt;JWT-Decoder mit Security-Analyse&lt;/a&gt; einfügen, der vollständig im Browser läuft.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fro1gn2inxt4tv9pdisb1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fro1gn2inxt4tv9pdisb1.webp" alt="JWT erklärt: Ein JSON Web Token funktioniert wie eine Bordkarte. Jeder kann sie lesen, wer sie hält, kommt rein, und nur der Aussteller kann das Siegel drucken; eine Karte mit alg none ist ungültig." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Was ist ein JWT? Ein JSON Web Token als Beispiel
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ein JWT (JSON Web Token, ausgesprochen wie das englische „jot”) ist ein kompakter, URL-sicherer String, der eine Menge von Claims und eine Signatur darüber trägt.&lt;/strong&gt; Definiert ist es in RFC 7519; die Formate für Signatur und Verschlüsselung darunter heißen JWS (RFC 7515) und JWE (RFC 7516).&lt;/p&gt;

&lt;p&gt;Ein signiertes JWT hat drei Teile, getrennt durch Punkte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImVkaXRvciIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.92XVsnsq7eHmUoHn1v4cCfyufbIv5l0Pm8L_Ec6PDH8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Dekodiert man die ersten beiden Teile aus Base64url, erhält man zwei gewöhnliche JSON-Objekte:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;header&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;em&gt;payload&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1767225600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1767226500&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Der dritte Teil ist die &lt;strong&gt;Signatur&lt;/strong&gt; : 32 Byte HMAC-SHA256 über &lt;code&gt;header.payload&lt;/code&gt;, berechnet mit einem Secret, das nur der Aussteller kennt. Ändert man ein Zeichen der Payload, etwa &lt;code&gt;"role": "admin"&lt;/code&gt;, passt die Signatur nicht mehr.&lt;/p&gt;

&lt;p&gt;Mehr ist ein JWT nicht: Claims plus Beweis, wer sie geschrieben hat. Spannend ist alles drumherum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aufbau eines JSON Web Tokens
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38ewj79o7mxrwa50k8w9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38ewj79o7mxrwa50k8w9.webp" alt="JWT-Aufbau: Header, Payload und Signatur sind Base64url-kodiert und durch Punkte verbunden; die Signatur wird mit dem Schlüssel des Ausstellers über den kodierten Header und die Payload berechnet." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Zwei lesbare JSON-Objekte und eine Signatur, die sie an einen Schlüssel bindet.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Der Header
&lt;/h3&gt;

&lt;p&gt;Der Header sagt, wie das Token zu prüfen ist. Die wichtigen Felder:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feld&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;th&gt;Vorsicht bei&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signaturalgorithmus: &lt;code&gt;HS256&lt;/code&gt;, &lt;code&gt;RS256&lt;/code&gt;, &lt;code&gt;ES256&lt;/code&gt;, &lt;code&gt;EdDSA&lt;/code&gt; …&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;none&lt;/code&gt; oder einem Algorithmus, den Ihr Server nie ausstellt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;typ&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tokentyp: &lt;code&gt;JWT&lt;/code&gt; oder &lt;code&gt;at+jwt&lt;/code&gt; für OAuth-Access-Tokens (RFC 9068)&lt;/td&gt;
&lt;td&gt;Ohne Typ werden Token-Verwechslungen leichter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key ID: welcher Schlüssel aus dem Key Set signiert hat&lt;/td&gt;
&lt;td&gt;Verwendung als Dateipfad oder SQL-Wert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jku&lt;/code&gt;, &lt;code&gt;x5u&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;URL eines Key Sets oder Zertifikats&lt;/td&gt;
&lt;td&gt;Wird vom Verifier blind abgerufen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jwk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Öffentlicher Schlüssel direkt im Token&lt;/td&gt;
&lt;td&gt;Wird als Prüfschlüssel vertraut&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Die Payload: Claims
&lt;/h3&gt;

&lt;p&gt;Die Payload ist ein JSON-Objekt aus &lt;strong&gt;Claims&lt;/strong&gt;. RFC 7519 registriert sieben davon; alles andere legt der Aussteller fest.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Was der Verifier damit tut&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iss&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Issuer (Aussteller)&lt;/td&gt;
&lt;td&gt;Muss zum vertrauenswürdigen Identity Provider passen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subject&lt;/td&gt;
&lt;td&gt;Der Nutzer oder Client, um den es geht&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Audience (Zielgruppe)&lt;/td&gt;
&lt;td&gt;Muss die Kennung &lt;em&gt;Ihrer&lt;/em&gt; API enthalten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expiration Time&lt;/td&gt;
&lt;td&gt;Nach diesem Zeitpunkt ablehnen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nbf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not Before&lt;/td&gt;
&lt;td&gt;Vor diesem Zeitpunkt ablehnen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Issued At&lt;/td&gt;
&lt;td&gt;Altersprüfung, Debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jti&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JWT ID&lt;/td&gt;
&lt;td&gt;Eindeutige ID für Einmalnutzung oder Widerruf&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zeiten sind &lt;strong&gt;NumericDate&lt;/strong&gt; : ganze Sekunden seit 1970-01-01 UTC. Erstaunlich viele Fehler entstehen, weil Millisekunden geschrieben werden (&lt;code&gt;Date.now()&lt;/code&gt; in JavaScript): Das Token läuft dann erst in Zehntausenden Jahren ab.&lt;/p&gt;
&lt;h3&gt;
  
  
  Die Signatur
&lt;/h3&gt;

&lt;p&gt;Bei HS256 lautet die Signatur:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Bei RS256, ES256 oder EdDSA ist es eine Public-Key-Signatur über dieselbe Eingabe. In beiden Fällen deckt sie den &lt;em&gt;kodierten&lt;/em&gt; Header und die Payload ab, der Server prüft also genau die Bytes, die er erhalten hat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signiert ist nicht verschlüsselt
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Base64url ist eine Kodierung, keine Verschlüsselung.&lt;/strong&gt; Wer ein JWT hat, liest jeden Claim, ganz ohne Schlüssel:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Terminal window&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'console.log(JSON.parse(Buffer.from(process.argv[1].split(".")[1], "base64url")))'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Das ist das häufigste Missverständnis rund um JWTs, und es hat Folgen. Tokens landen im Browser-Speicher, in Logs, Analytics, Crash-Reports und Proxy-Caches. Was in der Payload steht, veröffentlichen Sie an alle diese Orte.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Ein JWT ist eine Bordkarte. Wer sie sieht, liest den Namen darauf, wer sie hält, steigt ein, und nur der Aussteller kann das Siegel drucken, das sie gültig macht.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Die Payload also auf Kennungen und Berechtigungen beschränken. Keine Passwörter, API-Keys, Kartennummern und keine personenbezogenen Daten über das hinaus, was die API braucht. Muss der Inhalt wirklich vertraulich sein, ein verschlüsseltes JWT ( &lt;strong&gt;JWE&lt;/strong&gt; , fünf statt drei Teile) verwenden oder die Daten auf dem Server halten und eine undurchsichtige ID ins Token schreiben.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wie man ein JWT richtig prüft
&lt;/h2&gt;

&lt;p&gt;Dekodieren zeigt, was ein Token &lt;em&gt;behauptet&lt;/em&gt;. Prüfen entscheidet, ob man es glaubt. Die Prüfung ist eine Checkliste, und die Reihenfolge zählt: Jeder Schritt schließt einen bestimmten Angriff.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn2wqit2q2oc0365l6na6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn2wqit2q2oc0365l6na6.webp" alt="JWT-Prüfung als Checkliste: Größe begrenzen, Algorithmus festlegen, Schlüssel aus dem eigenen Key Set wählen, Signatur prüfen, dann exp und nbf, dann iss, aud und typ. Jeder Schritt blockiert einen benannten Angriff." width="799" height="481"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Die meisten JWT-Vorfälle überspringen einen dieser Schritte; keiner bricht die Kryptografie.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Eingabe begrenzen.&lt;/strong&gt; Übergroße Tokens vor dem Parsen ablehnen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Algorithmus festlegen.&lt;/strong&gt; Nur die Algorithmen akzeptieren, die Sie tatsächlich ausstellen, konfiguriert auf dem Server. Nie das &lt;code&gt;alg&lt;/code&gt; des Tokens entscheiden lassen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Den Schlüssel selbst wählen.&lt;/strong&gt; Die &lt;code&gt;kid&lt;/code&gt; im &lt;em&gt;eigenen&lt;/em&gt; Key Set nachschlagen (ein JWKS Ihres Identity Providers). &lt;code&gt;jwk&lt;/code&gt;, &lt;code&gt;jku&lt;/code&gt; und &lt;code&gt;x5u&lt;/code&gt; aus dem Token ignorieren.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signatur prüfen.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zeit prüfen:&lt;/strong&gt; &lt;code&gt;exp&lt;/code&gt; und &lt;code&gt;nbf&lt;/code&gt; mit kleiner Toleranz für Uhrabweichungen (30–60 Sekunden), und &lt;code&gt;exp&lt;/code&gt; als Pflichtfeld verlangen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kontext prüfen:&lt;/strong&gt; &lt;code&gt;iss&lt;/code&gt; ist Ihr Identity Provider, &lt;code&gt;aud&lt;/code&gt; enthält Ihre API, &lt;code&gt;typ&lt;/code&gt; ist das, was dieser Endpunkt erwartet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mit einer gepflegten Bibliothek sind das wenige Zeilen. In Node.js mit &lt;code&gt;jose&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;verify.js&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRemoteJWKSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jwtVerify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jose&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRemoteJWKSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://auth.example.com/.well-known/jwks.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://auth.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="na"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;at+jwt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="na"&gt;clockTolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Und in Python mit PyJWT:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;verify.py&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PyJWKClient&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://auth.example.com/.well-known/jwks.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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_signing_key_from_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;

&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;algorithms&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;ES256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;leeway&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;options&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;require&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&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;iat&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;iss&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;aud&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;sub&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Die hervorgehobenen Zeilen sind die Sicherheit. Streicht man eine davon, „funktioniert” der Code in jedem Test weiter, und genau deshalb werden sie gestrichen.&lt;/p&gt;

&lt;p&gt;Achtung&lt;/p&gt;

&lt;p&gt;Nie eine Decode-Funktion aufrufen, um Claims zu lesen und darauf eine Berechtigungsentscheidung zu stützen. Funktionen namens &lt;code&gt;decode&lt;/code&gt; überspringen in vielen Bibliotheken die Signaturprüfung, absichtlich oder per Flag. Nur Claims aus einem erfolgreichen &lt;em&gt;Verify&lt;/em&gt; sind Fakten.&lt;/p&gt;

&lt;h2&gt;
  
  
  HS256 vs. RS256 vs. ES256: welcher Algorithmus?
&lt;/h2&gt;

&lt;p&gt;Die eigentliche Frage ist nicht Geschwindigkeit oder Schlüssellänge, sondern &lt;strong&gt;wer Tokens ausstellen kann&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzhmu1ewu1n4p1c8ki6ki.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzhmu1ewu1n4p1c8ki6ki.webp" alt="HS256 vs. RS256 und ES256: Bei HS256 hält jeder Dienst, der Tokens prüft, das gemeinsame Secret und könnte Tokens fälschen; bei RS256 oder ES256 hält nur der Aussteller den privaten Schlüssel, Dienste prüfen mit dem öffentlichen JWKS." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Mit einem gemeinsamen Secret ist jeder Prüfer auch ein möglicher Aussteller.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Algorithmus&lt;/th&gt;
&lt;th&gt;Typ&lt;/th&gt;
&lt;th&gt;Signaturgröße&lt;/th&gt;
&lt;th&gt;Einsetzen, wenn&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HS256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HMAC, gemeinsames Secret&lt;/td&gt;
&lt;td&gt;32 Byte&lt;/td&gt;
&lt;td&gt;Ein Dienst seine eigenen Tokens ausstellt &lt;em&gt;und&lt;/em&gt; prüft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RS256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RSA PKCS#1 v1.5&lt;/td&gt;
&lt;td&gt;256 Byte (2048-Bit-Schlüssel)&lt;/td&gt;
&lt;td&gt;Breiteste Unterstützung; Standard der meisten Identity Provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PS256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RSA-PSS&lt;/td&gt;
&lt;td&gt;256 Byte (2048-Bit-Schlüssel)&lt;/td&gt;
&lt;td&gt;RSA mit dem moderneren Padding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ES256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ECDSA P-256&lt;/td&gt;
&lt;td&gt;64 Byte&lt;/td&gt;
&lt;td&gt;Kleine Tokens, breite Unterstützung; guter Standard für neue Systeme&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;EdDSA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ed25519&lt;/td&gt;
&lt;td&gt;64 Byte&lt;/td&gt;
&lt;td&gt;Einfachste sichere Signaturen, wo Ihre Bibliotheken es unterstützen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bei HS256 &lt;em&gt;ist&lt;/em&gt; der Prüfschlüssel der Signaturschlüssel. Prüfen fünf Microservices Tokens, halten fünf Dienste ein Secret, mit dem sie für jeden beliebigen Nutzer ein Token erzeugen können, und ein Leck im am schlechtesten geschützten Dienst kompromittiert alle. Mit einem asymmetrischen Verfahren hält nur der Identity Provider den privaten Schlüssel; Dienste holen die öffentlichen Schlüssel von einer JWKS-URL, und Schlüsselrotation bedeutet einfach eine neue &lt;code&gt;kid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Wer HS256 nutzt, braucht ein zufälliges, langes Secret: RFC 7518 verlangt einen Schlüssel mindestens so lang wie die Hash-Ausgabe (256 Bit bei HS256), und RFC 8725 schließt merkbare Passwörter als HMAC-Schlüssel aus.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Terminal window&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;h2&gt;
  
  
  Sechs JWT-Angriffe, die heute noch funktionieren
&lt;/h2&gt;

&lt;p&gt;Keiner davon bricht HMAC, RSA oder ECDSA. Sie nutzen den Verifier aus: Code, der dem Token glaubt, wie es geprüft werden will. Tim McLeans Analyse kritischer Schwachstellen in JWT-Bibliotheken von 2015 hat die ersten beiden berühmt gemacht, und sie tauchen in Eigenbau-Code und alten Abhängigkeiten immer noch auf.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;alg: none&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;JWS definiert einen „ungesicherten” Algorithmus namens &lt;code&gt;none&lt;/code&gt; mit leerer Signatur. Ein Angreifer nimmt ein gültiges Token, ändert die Payload, setzt &lt;code&gt;"alg": "none"&lt;/code&gt;, entfernt die Signatur und lässt den letzten Punkt stehen. Ein Verifier, der dem Header folgt, akzeptiert es. Varianten wie &lt;code&gt;None&lt;/code&gt; oder &lt;code&gt;nOnE&lt;/code&gt; rutschen an naiven Stringvergleichen vorbei.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; eine explizite Allow-List der Algorithmen auf dem Server, wie im Code oben.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Algorithm Confusion (RS256 zu HS256)
&lt;/h3&gt;

&lt;p&gt;Der Server erwartet RS256 und prüft mit dem &lt;em&gt;öffentlichen&lt;/em&gt; Schlüssel des Ausstellers. Der Angreifer ändert &lt;code&gt;alg&lt;/code&gt; auf &lt;code&gt;HS256&lt;/code&gt; und signiert das Token per HMAC mit genau diesem öffentlichen Schlüssel als Secret, der ja öffentlich ist. Eine Bibliothek, die den Algorithmus aus dem Header nimmt, rechnet nun HMAC mit denselben Schlüssel-Bytes, und das gefälschte Token ist gültig.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; den Algorithmus festlegen und Bibliotheken nutzen, die einen Schlüssel an genau einen Algorithmustyp binden.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Schwache HMAC-Secrets
&lt;/h3&gt;

&lt;p&gt;Ein HS256-Token ist ein Offline-Rätsel: Wer ein Token hat, kann Kandidaten-Secrets testen, ohne Ihren Server zu berühren. &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;changeme&lt;/code&gt;, der Beispielwert des Frameworks oder &lt;code&gt;your-256-bit-secret&lt;/code&gt; aus einem Tutorial fallen in Sekunden; hashcat hat sogar einen eigenen Modus für JWTs (16500).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; ein zufälliges 256-Bit-Secret aus einem Secret Manager oder der Wechsel zu einem asymmetrischen Verfahren.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Schlüssel-Injection über den Header
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;jwk&lt;/code&gt; bettet einen öffentlichen Schlüssel ins Token ein, &lt;code&gt;jku&lt;/code&gt; oder &lt;code&gt;x5u&lt;/code&gt; zeigen auf eine URL mit dem Schlüssel. Ein Verifier, der sie nutzt, lässt den Angreifer mit dem eigenen Schlüssel signieren und den passenden öffentlichen Schlüssel gleich mitliefern. &lt;code&gt;kid&lt;/code&gt; ist subtiler: Es ist nur ein String, und Code, der ihn als Dateipfad (&lt;code&gt;../../dev/null&lt;/code&gt;) oder in einer SQL-Abfrage verwendet, macht daraus Path Traversal oder Injection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Schlüssel nur aus dem eigenen, konfigurierten Key Set nehmen; &lt;code&gt;kid&lt;/code&gt; als undurchsichtigen Lookup-Schlüssel behandeln. RFC 8725 sagt es ausdrücklich: empfangenen Claims nicht vertrauen.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Fehlende Prüfung von Audience und Issuer
&lt;/h3&gt;

&lt;p&gt;Ihr Unternehmen hat zwei APIs, die demselben Identity Provider vertrauen. Ein Token für die harmlose API wird gegen die Payment-API wiederverwendet und ist gültig, denn Signatur und Ablauf stimmen. Niemand hat &lt;code&gt;aud&lt;/code&gt; geprüft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Jede API prüft, dass &lt;code&gt;aud&lt;/code&gt; sie selbst nennt und &lt;code&gt;iss&lt;/code&gt; der erwartete Aussteller ist. Explizite &lt;code&gt;typ&lt;/code&gt;-Werte verhindern, dass ein ID-Token als Access Token durchgeht.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Tokens, die ewig leben
&lt;/h3&gt;

&lt;p&gt;Ein Token ohne &lt;code&gt;exp&lt;/code&gt; oder mit Monaten Laufzeit ist ein langlebiges Passwort, das sich nicht ändern lässt. Ein gestohlenes funktioniert weiter, nachdem sich der Nutzer abmeldet, sein Passwort ändert oder das Unternehmen verlässt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Access Tokens mit Laufzeiten in Minuten, &lt;code&gt;exp&lt;/code&gt; als Pflichtfeld im Verifier und rotierende Refresh Tokens, die der Server widerrufen kann.&lt;/p&gt;

&lt;p&gt;Auch Bibliotheksfehler kommen vor: 2022 akzeptierte die ECDSA-Implementierung von Java (Versionen 15 bis 18) eine Signatur aus lauter Nullen als gültig für jede Nachricht. Damit war auf ungepatchten Laufzeitumgebungen jedes ES256-Token fälschbar (CVE-2022-21449, „Psychic Signatures”). Krypto-Laufzeiten und JWT-Bibliotheken aktuell zu halten, gehört zur Checkliste.&lt;/p&gt;

&lt;h2&gt;
  
  
  JWT-Decoder: ein Token lesen, ohne es preiszugeben
&lt;/h2&gt;

&lt;p&gt;Ein JWT ist ein &lt;strong&gt;Bearer-Credential&lt;/strong&gt; : Wer ein gültiges Produktions-Token hat, kann meist Ihre API im Namen dieses Nutzers aufrufen. Es in irgendeine Website einzufügen, die es an einen Server hochlädt, heißt, es zu verschenken.&lt;/p&gt;

&lt;p&gt;Drei sichere Wege:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kommandozeile:&lt;/strong&gt; der Node.js-Einzeiler oben oder das Pendant Ihrer Sprache. Nichts verlässt den Rechner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ein lokaler Browser-Decoder:&lt;/strong&gt; Der &lt;a href="https://www.alekseialeinikov.com/de/tools/jwt-analyzer" rel="noopener noreferrer"&gt;JWT-Decoder mit Security-Analyse&lt;/a&gt; auf dieser Website dekodiert, prüft und auditiert Tokens mit Web Crypto direkt in der Seite; Tokens, Secrets und Schlüssel werden nie hochgeladen, gespeichert oder in die URL geschrieben.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abgelaufene oder Test-Tokens&lt;/strong&gt; , wenn Sie nur die Struktur sehen wollen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So sieht der Bericht für ein Token aus, das mit &lt;code&gt;your-256-bit-secret&lt;/code&gt; signiert wurde, dem Beispiel-Secret aus unzähligen Tutorials:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frcpwsx6dnbqilnmaj03n.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frcpwsx6dnbqilnmaj03n.webp" alt="JWT-Decoder mit Security-Analyse zeigt Note F für ein HS256-Token: mit einem bekannten Secret signiert und vier Jahre gültig, mit dekodiertem Header und Payload und einer Lösung pro Fund." width="800" height="1143"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Der Analyzer probiert lokal über 100 bekannte Secrets und erklärt jeden Fund mit einem Fix.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Zuerst meldet er die fälschbare Signatur, dann die mehrjährige Laufzeit und die fehlenden &lt;code&gt;iss&lt;/code&gt; und &lt;code&gt;aud&lt;/code&gt;, jeweils mit Fix. Fügen Sie den öffentlichen Schlüssel oder das JWKS des Ausstellers ein, prüft er auch RS256-, PS256-, ES256- und EdDSA-Signaturen. Für KI-Agenten steht dieselbe Analyse als WebMCP-Tool &lt;code&gt;analyze_jwt&lt;/code&gt; bereit.&lt;/p&gt;

&lt;h2&gt;
  
  
  JWT oder Session-Cookie: wann ein JWT passt
&lt;/h2&gt;

&lt;p&gt;JWTs sind so populär, dass sie auch dort landen, wo eine Session einfacher wäre. Ein ehrlicher Vergleich:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Serverseitige Session&lt;/th&gt;
&lt;th&gt;JWT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zustand&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Auf dem Server, Cookie enthält eine ID&lt;/td&gt;
&lt;td&gt;In sich geschlossen, mit Schlüssel geprüft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Widerruf&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sofort: Session löschen&lt;/td&gt;
&lt;td&gt;Wartet auf &lt;code&gt;exp&lt;/code&gt;, außer man ergänzt Serverzustand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Skalierung&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Braucht gemeinsamen Session-Store&lt;/td&gt;
&lt;td&gt;Jeder Dienst mit dem öffentlichen Schlüssel kann prüfen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Über Dienste und Domains&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Umständlich&lt;/td&gt;
&lt;td&gt;Genau dafür gebaut&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Größe pro Request&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ein paar Dutzend Byte&lt;/td&gt;
&lt;td&gt;Hunderte Byte bis mehrere KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eine &lt;strong&gt;Session&lt;/strong&gt; für die klassische Web-App mit einem Backend. &lt;strong&gt;JWTs&lt;/strong&gt; , wenn mehrere Dienste, ein API-Gateway oder Dritte die Identität ohne gemeinsamen Store prüfen müssen, und überall dort, wo OAuth 2.0 und OpenID Connect ohnehin eines liefern: ID-Tokens sind immer JWTs, viele Access Tokens ebenfalls.&lt;/p&gt;

&lt;h3&gt;
  
  
  JWT vs. OAuth
&lt;/h3&gt;

&lt;p&gt;Das sind keine Alternativen. &lt;strong&gt;OAuth 2.0 ist ein Protokoll&lt;/strong&gt; , um im Namen eines Nutzers ein Access Token zu bekommen; &lt;strong&gt;ein JWT ist ein Tokenformat&lt;/strong&gt;. Ein OAuth-Access-Token kann ein JWT sein (RFC 9068 standardisiert dieses Profil mit &lt;code&gt;typ: at+jwt&lt;/code&gt;) oder ein undurchsichtiger Zufallsstring, den die API beim Authorization Server prüft. OpenID Connect, die Identitätsschicht über OAuth, nutzt für das ID-Token immer ein JWT.&lt;/p&gt;

&lt;p&gt;Beides lässt sich gut kombinieren. In Browser-Apps gehört ein JWT in ein HttpOnly-Cookie, nicht in den &lt;code&gt;localStorage&lt;/code&gt;; Details inklusive CSRF-Schutz stehen in &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/security/jwt-nicht-in-localstorage-cookie-auth-fuer-spas-2026" rel="noopener noreferrer"&gt;JWT nicht in LocalStorage speichern&lt;/a&gt;. Wer den Identity Provider selbst betreibt, lässt Tokens von &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/security/ist-keycloak-kostenlos-keycloak-vs-authentik-2026" rel="noopener noreferrer"&gt;Keycloak oder Authentik&lt;/a&gt; ausstellen und signieren, und ein stärkerer Login davor, etwa mit &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/security/passkeys-vs-passwoerter-wie-webauthn-funktioniert-2026" rel="noopener noreferrer"&gt;Passkeys&lt;/a&gt;, schützt den Moment, in dem ein Token entsteht.&lt;/p&gt;

&lt;p&gt;Das HMAC hinter HS256 ist dasselbe Primitiv, mit dem Webhooks signiert werden; &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/webhooks-erklaert-signatur-retries-idempotenz" rel="noopener noreferrer"&gt;Webhook-Signaturen&lt;/a&gt; unterliegen denselben Regeln für Secret-Stärke und zeitkonstanten Vergleich.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fazit
&lt;/h2&gt;

&lt;p&gt;Ein JWT ist ein lesbares JSON-Dokument mit Signatur. Das Format ist einfach und die Kryptografie solide; in der Praxis scheitert der Code drumherum. Algorithmus festlegen, Schlüssel selbst wählen, &lt;code&gt;exp&lt;/code&gt; verlangen, &lt;code&gt;iss&lt;/code&gt; und &lt;code&gt;aud&lt;/code&gt; prüfen, Tokens kurzlebig halten und Secrets aus der Payload heraushalten. Mit einer gepflegten Bibliothek umgesetzt, ist ein JWT eines der bequemsten Credentials, die man ausgeben kann.&lt;/p&gt;

&lt;p&gt;Und wenn Sie das nächste Mal sehen wollen, was in einem steckt: lokal dekodieren, nicht in einer Seite, die es hochlädt.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ursprünglich veröffentlicht auf &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/security/jwt-erklaert-json-web-token" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>jwt</category>
      <category>jsonwebtoken</category>
      <category>authentifizierung</category>
      <category>oauth</category>
    </item>
    <item>
      <title>PostgreSQL-Indizes: Welcher Typ wann? (10-Mio-Zeilen-Benchmark)</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Fri, 23 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/postgresql-indizes-welcher-typ-wann-10-mio-zeilen-benchmark-30om</link>
      <guid>https://dev.to/aleksei_aleinikov/postgresql-indizes-welcher-typ-wann-10-mio-zeilen-benchmark-30om</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k5ac8qxau06pyn9o5d9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k5ac8qxau06pyn9o5d9.webp" alt="PostgreSQL-Indizes: Welcher Typ wann? (10-Mio-Zeilen-Benchmark)" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Früher oder später tippt jedes Postgres-Team in Eile &lt;code&gt;CREATE INDEX&lt;/code&gt;, und die meisten tippen denselben. B-tree ist der richtige Standard, aber nicht immer die richtige Antwort. Auf einer Tabelle mit 10 Millionen Zeilen fand ich einen Index, der 3.000-mal kleiner und nur 3-mal langsamer war, einen Index, der gar nichts bewirkte, und einen „abdeckenden“ Index, der Abfragen beschleunigte, bis ein ganz normales &lt;code&gt;UPDATE&lt;/code&gt; den Vorteil wieder auffraß.&lt;/p&gt;

&lt;p&gt;Das ist &lt;strong&gt;Teil 1 von Postgres in Production&lt;/strong&gt; , einer Serie über die Entscheidungen, die zählen, sobald eine Datenbank das Tutorial verlässt: Indizes, VACUUM und Bloat, Connection Pooling, Partitionierung und JSONB. Jeder Teil folgt einer Regel: erst messen, dann erklären. Die Zahlen unten stammen von PostgreSQL 18.6 und einem Open-Source-Benchmark, den du selbst ausführen kannst.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k5ac8qxau06pyn9o5d9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k5ac8qxau06pyn9o5d9.webp" alt="PostgreSQL-Index-Typen im Vergleich auf 10 Millionen Zeilen: B-tree, Hash, BRIN und GIN, gemessen nach Geschwindigkeit, Indexgröße und Schreibkosten." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Welcher PostgreSQL-Index-Typ passt: die kurze Antwort
&lt;/h2&gt;

&lt;p&gt;Ein PostgreSQL-Index ist eine eigene Datenstruktur, mit der die Datenbank Zeilen findet, ohne die ganze Tabelle zu lesen. Der Typ bestimmt, welche Fragen er beantworten kann und wie viel Platz und Schreibzeit er kostet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deine Abfrage&lt;/th&gt;
&lt;th&gt;Index&lt;/th&gt;
&lt;th&gt;Gemessen auf 10 Millionen Zeilen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WHERE user_id = 42&lt;/code&gt;, Bereiche, &lt;code&gt;ORDER BY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;B-tree&lt;/strong&gt; (Standard)&lt;/td&gt;
&lt;td&gt;158 ms Scan, 0,11 ms mit Index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nur ein seltener Ausschnitt zählt (&lt;code&gt;status = 'failed'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Partieller&lt;/strong&gt; B-tree&lt;/td&gt;
&lt;td&gt;1,8 MiB statt 301 MiB, gleiche Geschwindigkeit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wenige Spalten, viele Zugriffe&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Abdeckender&lt;/strong&gt; B-tree (&lt;code&gt;INCLUDE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0,109 auf 0,078 ms, bei 4,4-facher Größe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zeitbereich auf geordneten Append-only-Daten&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;BRIN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;72 KiB statt 214 MiB, 3-mal langsamer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enthält: Arrays, &lt;code&gt;jsonb&lt;/code&gt;, Volltext, &lt;code&gt;LIKE '%x%'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;GIN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;360 ms auf 2,8 ms bei seltenem Tag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geometrie, Bereiche, nächster Nachbar&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;GiST&lt;/strong&gt; oder &lt;strong&gt;SP-GiST&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;hier nicht gemessen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nur Gleichheit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;nicht schneller als B-tree, 3-mal größer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkculdaek6lqie11vsgrl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkculdaek6lqie11vsgrl.webp" alt="Entscheidungskarte für PostgreSQL-Index-Typen: von der Form der Abfrage zu B-tree, partiell, abdeckend, BRIN, GIN, GiST oder Hash." width="800" height="555"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Fang bei der Abfrage an, nicht beim Index-Typ.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Wie ein Index eine Zeile findet und was ein Scan kostet
&lt;/h2&gt;

&lt;p&gt;Ohne Index liest PostgreSQL die Tabelle Seite für Seite und prüft jede Zeile. Die Testtabelle ist 1,6 GB groß, 208.376 Seiten à 8 KB, und ein Sequential Scan hat jede einzelne gezählt. Ein B-tree-Index auf derselben Tabelle ist ein drei bis vier Ebenen tiefer Baum, ein Schlüssel kostet also eine Handvoll Seiten: Die Abfrage unten las &lt;strong&gt;14 Buffer statt 208.376&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Das ist die ganze Idee, alles andere sind Abwägungen. Ein größerer Index beantwortet mehr Arten von Fragen, kostet aber Platz, Aufbauzeit und Schreibzeit. Der Planer wählt anhand geschätzter Kosten zwischen Scan und Index, deshalb wird ein vorhandener Index nicht immer benutzt (mehr dazu unten). Was wirklich passiert ist, zeigt &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;. Seit PostgreSQL 18 ist &lt;code&gt;BUFFERS&lt;/code&gt; bei &lt;code&gt;ANALYZE&lt;/code&gt; automatisch dabei.&lt;/p&gt;
&lt;h2&gt;
  
  
  Der Benchmark: eine Tabelle, 10 Millionen Zeilen, und was jeder Index kostet
&lt;/h2&gt;

&lt;p&gt;Eine Tabelle &lt;code&gt;events&lt;/code&gt; mit bigint-Primärschlüssel, &lt;code&gt;user_id&lt;/code&gt; (zufällig, etwa 10 Zeilen pro Nutzer), &lt;code&gt;status&lt;/code&gt; (70 % &lt;code&gt;done&lt;/code&gt;, 20 % &lt;code&gt;active&lt;/code&gt;, 9 % &lt;code&gt;pending&lt;/code&gt;, 0,85 % &lt;code&gt;failed&lt;/code&gt;, 0,15 % &lt;code&gt;archived&lt;/code&gt;), &lt;code&gt;created_at&lt;/code&gt; (eine Zeile alle 3 Sekunden, wächst also mit &lt;code&gt;id&lt;/code&gt;), einer Spalte &lt;code&gt;tags text[]&lt;/code&gt; und einer Spalte &lt;code&gt;payload jsonb&lt;/code&gt;. Die Tabelle ist 1,6 GB groß, der Primärschlüssel 214 MiB. Die Daten kommen aus einem festen Zufalls-Seed, jeder Lauf erzeugt also dieselben Zeilen.&lt;/p&gt;

&lt;p&gt;Jede Abfrage lief einmal zum Aufwärmen und danach dreimal, die Tabellen zeigen den Median. Punkt- und Bereichsabfragen wurden für mehrere Parameterwerte wiederholt. &lt;strong&gt;Jede gemessene Abfrage lief außerdem mit deaktivierten Index-Scans, und die Antworten mussten übereinstimmen&lt;/strong&gt; , sonst bricht das Skript ab. PostgreSQL 18.6, warmer Cache, &lt;code&gt;autovacuum&lt;/code&gt; aus, damit die Visibility Map unter Kontrolle bleibt (eine Produktionsdatenbank sollte so nicht laufen).&lt;/p&gt;

&lt;p&gt;Hinweis&lt;/p&gt;
&lt;p&gt;Nachstellen in drei Befehlen: &lt;code&gt;git clone https://github.com/LF3551/postgres-index-benchmark&lt;/code&gt;, dann &lt;code&gt;npm run up&lt;/code&gt; und &lt;code&gt;npm run all&lt;/code&gt;. Das &lt;a href="https://github.com/LF3551/postgres-index-benchmark" rel="noopener noreferrer"&gt;Repository&lt;/a&gt; startet ein festgelegtes PostgreSQL 18.6 in Docker, lädt dieselben Daten, führt jedes Experiment unten aus und gibt die Tabellen aus. Du kannst es auch über die &lt;code&gt;PG*&lt;/code&gt;-Variablen auf deinen eigenen Server richten. Messwerte von einer einzelnen Maschine sind ein Plausibilitätscheck, keine Rangliste: Vergleiche Verhältnisse, Größen und Pläne, nicht Millisekunden.&lt;/p&gt;
&lt;h2&gt;
  
  
  B-tree: der Standard aus gutem Grund
&lt;/h2&gt;

&lt;p&gt;B-tree ist ein balancierter Baum aus sortierten Schlüsseln. Weil die Schlüssel sortiert sind, beantwortet eine Struktur &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;BETWEEN&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt; und &lt;code&gt;IS NULL&lt;/code&gt;, und er ist das, was &lt;code&gt;CREATE INDEX&lt;/code&gt; baut, solange du nichts anderes verlangst.&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_user&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;424242&lt;/span&gt;&lt;span class="p"&gt;;&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;Suche nach &lt;code&gt;user_id&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Zeit&lt;/th&gt;
&lt;th&gt;Buffer&lt;/th&gt;
&lt;th&gt;Indexgröße&lt;/th&gt;
&lt;th&gt;Aufbau&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ohne Index (Sequential Scan)&lt;/td&gt;
&lt;td&gt;158 ms&lt;/td&gt;
&lt;td&gt;208.376&lt;/td&gt;
&lt;td&gt;keiner&lt;/td&gt;
&lt;td&gt;keiner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B-tree&lt;/td&gt;
&lt;td&gt;0,112 ms&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;88,5 MiB&lt;/td&gt;
&lt;td&gt;1,4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Das ist etwa 1.400-mal schneller für einen zusätzlichen Index von 88,5 MiB, aufgebaut in 1,4 Sekunden auf einer 1,6-GB-Tabelle. Der Index ist klein, weil B-tree jeden doppelten Schlüssel nur einmal speichert und eine Liste von Zeilenzeigern führt (Deduplizierung, seit PostgreSQL 13). Derselbe Index auf einer Zeitspalte, in der jeder Schlüssel einzigartig ist, ist 214 MiB groß.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Wenn eine Spalte im &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;JOIN&lt;/code&gt; oder &lt;code&gt;ORDER BY&lt;/code&gt; einer häufigen Abfrage steht, die wenige Zeilen liefert, ist ein B-tree fast immer die Antwort. Die anderen Typen gibt es für das, was B-tree schlecht kann.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Hash-Index: nicht besser als B-tree
&lt;/h2&gt;

&lt;p&gt;Ein Hash-Index speichert einen Hash des Schlüssels und unterstützt nur Gleichheit. Die Theorie sagt, er müsse bei Punktabfragen schneller sein. In diesem Test war er es nicht:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;user_id = ?&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Suche&lt;/th&gt;
&lt;th&gt;Indexgröße&lt;/th&gt;
&lt;th&gt;Aufbau&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;B-tree&lt;/td&gt;
&lt;td&gt;0,112 ms&lt;/td&gt;
&lt;td&gt;88,5 MiB&lt;/td&gt;
&lt;td&gt;1,4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash&lt;/td&gt;
&lt;td&gt;0,115 ms&lt;/td&gt;
&lt;td&gt;267,6 MiB&lt;/td&gt;
&lt;td&gt;13,0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gleiche Geschwindigkeit, dreifacher Platz, neunfache Aufbauzeit, und keine Bereiche oder Sortierung. Hash-Indizes sind seit PostgreSQL 10 absturzsicher, gefährlich sind sie also nicht, aber bei vielen doppelten Schlüsseln haben sie keinen Vorteil gegenüber einem B-tree, der dedupliziert. Das ist ein Datensatz, kein Gesetz: Bei langen String-Schlüsseln kann Hashing Platz sparen, miss also, bevor du ihn abschreibst.&lt;/p&gt;

&lt;h2&gt;
  
  
  BRIN: ein 72-KiB-Index für zeitlich geordnete Daten
&lt;/h2&gt;

&lt;p&gt;Ein BRIN (Block Range Index) speichert für jeden Bereich von Tabellenseiten eine Zusammenfassung, normalerweise Minimum und Maximum. Eine Abfrage überspringt jeden Bereich, dessen Zusammenfassung nicht passen kann. Er ist winzig, funktioniert aber nur, wenn die Werte der Spalte der physischen Reihenfolge der Tabelle folgen, und ein Append-only-&lt;code&gt;created_at&lt;/code&gt; tut genau das: &lt;code&gt;pg_stats&lt;/code&gt; meldet hier eine Korrelation von 1,0 für &lt;code&gt;created_at&lt;/code&gt; und etwa 0 für &lt;code&gt;user_id&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_ts_brin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Die Abfrage: ein Tag Ereignisse, 28.800 Zeilen von 10 Millionen.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ein Tag nach &lt;code&gt;created_at&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Zeit&lt;/th&gt;
&lt;th&gt;Buffer&lt;/th&gt;
&lt;th&gt;Indexgröße&lt;/th&gt;
&lt;th&gt;Aufbau&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ohne Index&lt;/td&gt;
&lt;td&gt;635 ms&lt;/td&gt;
&lt;td&gt;208.376&lt;/td&gt;
&lt;td&gt;keiner&lt;/td&gt;
&lt;td&gt;keiner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B-tree&lt;/td&gt;
&lt;td&gt;3,0 ms&lt;/td&gt;
&lt;td&gt;690&lt;/td&gt;
&lt;td&gt;214,2 MiB&lt;/td&gt;
&lt;td&gt;1,7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BRIN (Standardbereich von 128 Seiten)&lt;/td&gt;
&lt;td&gt;9,1 ms&lt;/td&gt;
&lt;td&gt;785&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;72 KiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0,2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BRIN (&lt;code&gt;pages_per_range = 32&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;8,2 ms&lt;/td&gt;
&lt;td&gt;683&lt;/td&gt;
&lt;td&gt;232 KiB&lt;/td&gt;
&lt;td&gt;0,2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;B-tree ist dreimal schneller, und BRIN ist &lt;strong&gt;etwa 3.000-mal kleiner&lt;/strong&gt;. Bei einer Tabelle, die um Milliarden Zeilen wächst, ist das der Unterschied zwischen einem Index, der in den Speicher passt, und einem, der es nicht tut, und ein BRIN-Index lässt sich in Sekundenbruchteilen neu bauen. Wenn deine Abfragen immer „der letzte Tag“ oder „der letzte Monat“ lauten, ist BRIN die günstige Wahl. Wenn eine einzelne Suche unter einer Millisekunde antworten muss, nimm B-tree. Ein kleineres &lt;code&gt;pages_per_range&lt;/code&gt; macht den Index etwas größer und schärfer.&lt;/p&gt;

&lt;p&gt;Achtung&lt;/p&gt;

&lt;p&gt;BRIN auf einer Spalte, die nicht physisch geordnet ist, ist nutzlos: Er kann keinen Blockbereich überspringen, und der Planer benutzt ihn nicht. Prüfe zuerst &lt;code&gt;SELECT attname, correlation FROM pg_stats WHERE tablename = 'events';&lt;/code&gt;. Wenn Zeilen aktualisiert und verschoben werden, sinkt die Korrelation, und BRIN verfällt mit ihr.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partielle Indizes: nur die Zeilen indizieren, die zählen
&lt;/h2&gt;

&lt;p&gt;Ein partieller Index hat eine &lt;code&gt;WHERE&lt;/code&gt;-Klausel und enthält nur die passenden Zeilen. Er ist die günstigste Optimierung in diesem Artikel, weil viele Abfragen einen kleinen, klar definierten Ausschnitt betreffen: unverarbeitete Jobs, fehlgeschlagene Zahlungen, aktive Sessions.&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_failed_ts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&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;created_at&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'failed'&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;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&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;Die neuesten 50 fehlgeschlagenen Ereignisse&lt;/th&gt;
&lt;th&gt;Zeit&lt;/th&gt;
&lt;th&gt;Indexgröße&lt;/th&gt;
&lt;th&gt;Aufbau&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ohne Index&lt;/td&gt;
&lt;td&gt;177 ms&lt;/td&gt;
&lt;td&gt;keiner&lt;/td&gt;
&lt;td&gt;keiner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voller Index &lt;code&gt;(status, created_at)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0,138 ms&lt;/td&gt;
&lt;td&gt;301,0 MiB&lt;/td&gt;
&lt;td&gt;3,5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partieller Index &lt;code&gt;WHERE status = 'failed'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0,125 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,8 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0,2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fehlgeschlagene Ereignisse sind 0,85 % der Zeilen. Der partielle Index beantwortet die Abfrage so schnell wie der volle und ist &lt;strong&gt;etwa 160-mal kleiner&lt;/strong&gt; , baut in einem Bruchteil der Zeit und kostet beim Schreiben fast nichts, weil 99 % der Inserts ihn nie berühren. Die Regel: Das &lt;code&gt;WHERE&lt;/code&gt; der Abfrage muss das &lt;code&gt;WHERE&lt;/code&gt; des Index implizieren, schreibe die Bedingung also in beiden gleich. Ein Parameter wie &lt;code&gt;status = $1&lt;/code&gt; kann den Planer in einem generischen Plan daran hindern, den Treffer zu beweisen, prüfe mit &lt;code&gt;EXPLAIN&lt;/code&gt;, ob der partielle Index genutzt wird.&lt;/p&gt;

&lt;h2&gt;
  
  
  Abdeckende Indizes: schneller, aber nur auf einer sauberen Tabelle
&lt;/h2&gt;

&lt;p&gt;Ein Index-Only-Scan beantwortet eine Abfrage allein aus dem Index, ohne die Tabelle zu besuchen. &lt;code&gt;INCLUDE&lt;/code&gt; (ab PostgreSQL 11) fügt dem Index genau dafür zusätzliche Spalten hinzu.&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_user_cov&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;424242&lt;/span&gt;&lt;span class="p"&gt;;&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;Abdeckender Index gegen einfachen B-tree&lt;/th&gt;
&lt;th&gt;Zeit&lt;/th&gt;
&lt;th&gt;Buffer&lt;/th&gt;
&lt;th&gt;Indexgröße&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Einfacher B-tree auf &lt;code&gt;user_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0,109 ms&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;88,5 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Abdeckend, Tabelle frisch vakuumiert&lt;/td&gt;
&lt;td&gt;0,078 ms&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;386,7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Abdeckend, nach Update von 1 % der Zeilen&lt;/td&gt;
&lt;td&gt;0,109 ms&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;386,7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Abdeckend, nach &lt;code&gt;VACUUM&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0,079 ms&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;386,7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zwei Lehren. Erstens: Der Gewinn ist echt, aber bescheiden (1,4-fach), während der Index &lt;strong&gt;4,4-mal größer&lt;/strong&gt; ist: Für eine Abfrage mit zehn Ergebniszeilen ist ein einfacher B-tree schon gut. Abdeckende Indizes lohnen sich in der Theorie mehr, wenn eine Abfrage viele Zeilen aus einem Indexbereich liefert und jeder Heap-Zugriff zählt; miss das auf deinen eigenen Daten. Zweitens: Ein Index-Only-Scan prüft weiterhin die Visibility Map, um zu sehen, ob eine Seite vollständig sichtbar ist, und ein &lt;code&gt;UPDATE&lt;/code&gt; löscht diese Bits. Nachdem 1 % der Zeilen angefasst wurden, verteilt über jede Seite, war der Vorteil weg, bis &lt;code&gt;VACUUM&lt;/code&gt; wieder lief. Autovacuum übernimmt das normalerweise; der Benchmark schaltet es ab, um den Effekt sichtbar zu machen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mehrspaltige Indizes und Skip Scan in PostgreSQL 18
&lt;/h2&gt;

&lt;p&gt;Ein mehrspaltiger B-tree sortiert nach der ersten Spalte, dann innerhalb jedes ersten Werts nach der zweiten, die Reihenfolge der Spalten zählt also. Vor PostgreSQL 18 konnte eine Abfrage, die nur nach der zweiten Spalte filterte, so einen Index nicht effizient nutzen. PostgreSQL 18 bringt &lt;strong&gt;Skip Scan&lt;/strong&gt; , der durch die verschiedenen Werte der führenden Spalte springt.&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_status_ts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&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="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- gar kein Filter auf status:&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;events&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2025-02-12'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2025-02-12 01:00'&lt;/span&gt;&lt;span class="p"&gt;;&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;Eine Stunde nach &lt;code&gt;created_at&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Zeit&lt;/th&gt;
&lt;th&gt;Buffer&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ohne Index&lt;/td&gt;
&lt;td&gt;233 ms&lt;/td&gt;
&lt;td&gt;210.460&lt;/td&gt;
&lt;td&gt;Seq Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index &lt;code&gt;(status, created_at)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0,246 ms&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;Index Only Scan, &lt;strong&gt;Index Searches: 11&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; meldet jetzt &lt;code&gt;Index Searches&lt;/code&gt;: 11 Abstiege durch den Index statt einem, was immer noch winzig ist. Skip Scan macht einen Index nützlicher, den du ohnehin hast. Er funktioniert, wenn die übersprungene Spalte wenige verschiedene Werte hat (hier fünf Status). Bei Millionen verschiedener führender Werte lässt er nach, ersetzt also keinen eigenen Index auf die Spalte, nach der du filterst.&lt;/p&gt;

&lt;h2&gt;
  
  
  GIN: wenn eine Zeile viele Werte enthält
&lt;/h2&gt;

&lt;p&gt;Ein GIN (Generalized Inverted Index) bildet jedes Element auf die Zeilen ab, die es enthalten, wie das Register hinten in einem Buch. Er ist der Index für Arrays, &lt;code&gt;jsonb&lt;/code&gt;, Volltextsuche (&lt;code&gt;tsvector&lt;/code&gt;) und, mit der Erweiterung &lt;code&gt;pg_trgm&lt;/code&gt;, für &lt;code&gt;LIKE '%text%'&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_tags&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_payload&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb_path_ops&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;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rare'&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;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"beta": true}'&lt;/span&gt;&lt;span class="p"&gt;;&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;Abfrage&lt;/th&gt;
&lt;th&gt;Treffer&lt;/th&gt;
&lt;th&gt;Ohne Index&lt;/th&gt;
&lt;th&gt;GIN&lt;/th&gt;
&lt;th&gt;Indexgröße&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Array, seltener Tag&lt;/td&gt;
&lt;td&gt;1.042 Zeilen&lt;/td&gt;
&lt;td&gt;360 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2,8 ms&lt;/strong&gt; (129x)&lt;/td&gt;
&lt;td&gt;39,1 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Array, häufiger Tag&lt;/td&gt;
&lt;td&gt;99.778 Zeilen (1 %)&lt;/td&gt;
&lt;td&gt;364 ms&lt;/td&gt;
&lt;td&gt;113 ms (3,2x)&lt;/td&gt;
&lt;td&gt;39,1 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonb @&amp;gt;&lt;/code&gt; seltenes Flag&lt;/td&gt;
&lt;td&gt;5.064 Zeilen&lt;/td&gt;
&lt;td&gt;353 ms&lt;/td&gt;
&lt;td&gt;34,1 ms (10x) mit Standard-Ops&lt;/td&gt;
&lt;td&gt;86,0 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonb @&amp;gt;&lt;/code&gt; seltenes Flag&lt;/td&gt;
&lt;td&gt;5.064 Zeilen&lt;/td&gt;
&lt;td&gt;353 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;9,9 ms&lt;/strong&gt; (36x) mit &lt;code&gt;jsonb_path_ops&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;44,5 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonb @&amp;gt;&lt;/code&gt; Land und Tarif&lt;/td&gt;
&lt;td&gt;250.512 Zeilen (2,5 %)&lt;/td&gt;
&lt;td&gt;399 ms&lt;/td&gt;
&lt;td&gt;347 ms (1,1x) Standard, 262 ms (1,5x) &lt;code&gt;jsonb_path_ops&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;86,0 / 44,5 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9o3yjn8sq1yp6a3d6fli.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9o3yjn8sq1yp6a3d6fli.webp" alt="PostgreSQL-Indexgröße gegen Suchzeit auf 10 Millionen Zeilen: BRIN ist 72 KiB, ein partieller Index 1,8 MiB, B-tree 88 bis 214 MiB, ein Hash-Index 268 MiB und GIN 39 bis 86 MiB." width="800" height="520"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Indexgröße auf logarithmischer Skala. Der günstigste Index ist selten der langsamste.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Drei Dinge zum Mitnehmen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Die Selektivität entscheidet alles.&lt;/strong&gt; GIN glänzt, wenn der Treffer selten ist (129-mal schneller), und hilft kaum, wenn der Treffer 2,5 % der Tabelle sind, weil die Heap-Seiten trotzdem gelesen werden müssen. Eine unselektive Enthält-Abfrage ist ein Hinweis auf das Datenmodell, kein Indexproblem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probiere bei &lt;code&gt;jsonb&lt;/code&gt; &lt;code&gt;jsonb_path_ops&lt;/code&gt;.&lt;/strong&gt; Er ist etwa halb so groß und bei &lt;code&gt;@&amp;gt;&lt;/code&gt;-Abfragen schneller, unterstützt dafür aber die Operatoren zur Schlüsselprüfung (&lt;code&gt;?&lt;/code&gt;, &lt;code&gt;?|&lt;/code&gt;, &lt;code&gt;?&amp;amp;&lt;/code&gt;) nicht.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallele Builds sind neu.&lt;/strong&gt; PostgreSQL 18 kann einen GIN-Index mit parallelen Workern bauen: 3,8 s mit vier Workern gegenüber 14,4 s seriell beim &lt;code&gt;tags&lt;/code&gt;-Index, 3,8-mal schneller, was beim Neuaufbau großer Tabellen zählt.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  GiST und SP-GiST: für Formen und Bereiche
&lt;/h2&gt;

&lt;p&gt;Diese habe ich auf diesen Daten nicht gemessen, weil die Testtabelle keine Geometrie und keine Bereiche enthält, und ein erfundener Benchmark würde nur mein Schema messen. &lt;strong&gt;GiST&lt;/strong&gt; ist ein Rahmen für baumbasierte Indizes über Daten ohne lineare Ordnung: geometrische Typen (PostGIS nutzt ihn), Bereichstypen und Nächste-Nachbarn-Suche (&lt;code&gt;ORDER BY location &amp;lt;-&amp;gt; point&lt;/code&gt;). Er stützt auch Exclusion Constraints wie „zwei Buchungen desselben Raums dürfen sich nicht überschneiden“. &lt;strong&gt;SP-GiST&lt;/strong&gt; unterstützt raumpartitionierte Strukturen wie Quad-Trees und Radix-Tries, nützlich für unbalancierte Daten wie IP-Adressen oder Telefonvorwahlen. Wenn deine Abfrage &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; oder &lt;code&gt;@&amp;gt;&lt;/code&gt; auf Formen oder Bereichen nutzt, fang hier an.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wenn der Planer deinen Index ignoriert
&lt;/h2&gt;

&lt;p&gt;Der häufigste Index-Fehler ist nicht der falsche Typ, sondern eine Abfrage, die der Index nicht bedienen kann. Zwei Fälle, gemessen auf derselben Tabelle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ein Tag nach &lt;code&gt;created_at&lt;/code&gt; oder ein Status-Filter&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Zeit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at &amp;gt;= ... AND created_at &amp;lt; ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;3,0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at::date = '2025-04-01'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Seq Scan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;329 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dasselbe, mit Ausdrucks-Index auf &lt;code&gt;(created_at AT TIME ZONE 'UTC')::date&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;2,7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;status = 'done'&lt;/code&gt; (70 % der Zeilen), Index auf &lt;code&gt;status&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Seq Scan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;263 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;status = 'archived'&lt;/code&gt; (0,15 % der Zeilen), derselbe Index&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;20,9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eine indizierte Spalte in eine Funktion oder einen Cast zu packen macht den Index unbrauchbar. Schreibe die Bedingung also als Bereich um oder indiziere den Ausdruck. Beachte, dass &lt;code&gt;timestamptz::date&lt;/code&gt; von der Zeitzone der Session abhängt und sich deshalb nicht direkt indizieren lässt: Der Ausdrucks-Index nutzt &lt;code&gt;AT TIME ZONE 'UTC'&lt;/code&gt;. Und wenn eine Abfrage den Großteil der Tabelle trifft, hat der Planer recht, den Index zu überspringen: 70 % einer Tabelle über einen Index zu lesen ist langsamer als ein Sequential Scan.&lt;/p&gt;
&lt;h2&gt;
  
  
  Die Schreibsteuer: Jeder Index kostet bei jedem Insert
&lt;/h2&gt;

&lt;p&gt;Lesen ist nur die halbe Rechnung. Jeder Insert muss in jedem Index der Tabelle einen Eintrag ergänzen. Ich habe 1 Million Zeilen in eine leere Tabelle mit Primärschlüssel und dann 0, 1, 3 und 5 weiteren Indizes eingefügt:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sekundäre Indizes&lt;/th&gt;
&lt;th&gt;1 Mio. Zeilen einfügen&lt;/th&gt;
&lt;th&gt;Im Vergleich zu nur Primärschlüssel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1,9 s&lt;/td&gt;
&lt;td&gt;1,0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 (B-tree auf &lt;code&gt;user_id&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;4,3 s&lt;/td&gt;
&lt;td&gt;2,3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 (dazu &lt;code&gt;created_at&lt;/code&gt; und &lt;code&gt;(status, created_at)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;5,8 s&lt;/td&gt;
&lt;td&gt;3,1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 (dazu GIN auf &lt;code&gt;tags&lt;/code&gt; und auf &lt;code&gt;payload&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;12,1 s&lt;/td&gt;
&lt;td&gt;6,4x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhooj9x03ivrant9fh2a.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhooj9x03ivrant9fh2a.webp" alt="PostgreSQL-Schreibkosten: 1 Million Zeilen einzufügen dauert mit nur einem Primärschlüssel 1,9 s und mit fünf weiteren Indizes, zwei davon GIN, 12,1 s." width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Der erste zusätzliche Index und die GIN-Indizes kosten am meisten.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Der Sprung von 3 auf 5 Indizes, mehr als eine Verdopplung der Zeit, sind die beiden GIN-Indizes: Sie sind am teuersten in der Pflege, und PostgreSQL puffert GIN-Updates standardmäßig in einer Pending-Liste, um das abzumildern. Indizes verlangsamen auch &lt;code&gt;UPDATE&lt;/code&gt; (wenn sich eine indizierte Spalte ändert) und &lt;code&gt;VACUUM&lt;/code&gt;, und sie brauchen Speicher und Platte. Ein Index, den du nicht nutzt, ist reine Kostenstelle.&lt;/p&gt;
&lt;h2&gt;
  
  
  Zufällige UUID-Schlüssel: versteckte Indexkosten
&lt;/h2&gt;

&lt;p&gt;Ein Primärschlüssel ist ebenfalls ein Index, und sein Typ bestimmt, wie der B-tree wächst. Eine zufällige UUIDv4 landet irgendwo im Baum, Inserts berühren also Seiten im ganzen Baum und spalten sie. Eine UUIDv7 beginnt mit einem Zeitstempel, neue Schlüssel kommen also der Reihe nach am rechten Rand an, wie bei einer Sequenz. PostgreSQL 18 hat dafür ein eingebautes &lt;code&gt;uuidv7()&lt;/code&gt;. Ich habe 5 Millionen Zeilen in eine Tabelle mit je einer Art von Primärschlüssel eingefügt:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primärschlüssel&lt;/th&gt;
&lt;th&gt;5 Mio. Zeilen einfügen&lt;/th&gt;
&lt;th&gt;Größe des Primärschlüssels&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UUIDv4 (&lt;code&gt;gen_random_uuid()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;12,9 s&lt;/td&gt;
&lt;td&gt;191 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UUIDv7 (&lt;code&gt;uuidv7()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;9,6 s&lt;/td&gt;
&lt;td&gt;150 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Der zeitlich geordnete Schlüssel fügte 1,3-mal schneller ein und erzeugte einen etwa ein Fünftel kleineren Index. Diese Tabellen passen in den Speicher, der Abstand dürfte also wachsen, sobald der Index nicht mehr hineinpasst, weil zufällige Schlüssel dann bei jedem Insert den Cache verfehlen. UUIDv7 verrät außerdem die Erstellungszeit in der ID, was zählt, wenn die ID öffentlich ist. Eine bigint-Sequenz ist noch kleiner (8 gegen 16 Byte).&lt;/p&gt;
&lt;h2&gt;
  
  
  So findest du ungenutzte und fehlende Indizes
&lt;/h2&gt;

&lt;p&gt;Rate nicht, frag die Statistik. Indizes, die seit dem letzten Zurücksetzen der Statistik nie gescannt wurden (&lt;code&gt;pg_stat_database.stats_reset&lt;/code&gt; zeigt, wie lange die Zähler schon laufen):&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idx_scan&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;

&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idx_scan&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;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indisunique&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indisprimary&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;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&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;Lass eindeutige und Primärschlüssel-Indizes aus, sie erzwingen Constraints. Beobachte über einen vollen Geschäftszyklus (Monatsabschluss-Jobs), und denk daran, dass jedes Replikat eigene Zähler führt. Für fehlende Indizes schau auf Tabellen, bei denen Sequential Scans viele Zeilen lesen:&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq_tup_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_live_tup&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;seq_scan&lt;/span&gt; &lt;span class="o"&gt;&amp;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="n"&gt;seq_tup_read&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Finde dann die Abfragen dahinter mit &lt;code&gt;pg_stat_statements&lt;/code&gt; und prüfe den Plan mit &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;. Auf einem Live-System baust du immer mit &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;, das Schreibzugriffe nicht blockiert (es kann nicht in einer Transaktion laufen, und ein fehlgeschlagener Build hinterlässt einen &lt;code&gt;INVALID&lt;/code&gt;-Index, den du löschen musst), und entfernst Indizes mit &lt;code&gt;DROP INDEX CONCURRENTLY&lt;/code&gt;. Ein Index, der nach vielen Updates und Deletes aufgebläht ist, lässt sich ohne Blockieren von Schreibzugriffen mit &lt;code&gt;REINDEX INDEX CONCURRENTLY&lt;/code&gt; neu bauen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was sich bei Indizes in PostgreSQL 18 und 19 ändert
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 18, das aktuelle Major-Release, brachte vier indexbezogene Änderungen, die in diesem Artikel vorkommen: Skip Scan für mehrspaltige B-tree-Indizes, parallele GIN-Builds, &lt;code&gt;Index Searches&lt;/code&gt; und automatisches &lt;code&gt;BUFFERS&lt;/code&gt; bei &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; sowie das eingebaute &lt;code&gt;uuidv7()&lt;/code&gt; für indexfreundliche Schlüssel. Es hat außerdem das neue asynchrone I/O-Subsystem eingeführt, das die Sequential und Bitmap Scans beschleunigt, gegen die diese Indizes antreten. PostgreSQL 19 ist im Release-Candidate-Stadium, und PostgreSQL 14 erreicht am 12. November 2026 das Support-Ende, jetzt ist also ein guter Zeitpunkt zu prüfen, welche Version deine eigene Datenbank nutzt. Die &lt;a href="https://www.postgresql.org/docs/18/release-18.html" rel="noopener noreferrer"&gt;offiziellen Release Notes&lt;/a&gt; haben die vollständige Liste, und wenn du dich noch für eine Datenbank entscheidest, vergleicht &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/postgres-vs-mysql-2026-vergleich" rel="noopener noreferrer"&gt;Postgres vs. MySQL 2026&lt;/a&gt; die beiden.&lt;/p&gt;

&lt;p&gt;Hinweis&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Als Nächstes in Postgres in Production:&lt;/strong&gt; VACUUM und Bloat (warum eine Tabelle wächst, obwohl du löschst), Connection Pooling, Partitionierung und JSONB im Detail. Indizes sind der erste Hebel für Performance, und die nächsten Teile behandeln, was sie nach einem Jahr Produktion schnell hält.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eine praktische Checkliste
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Schreibe zuerst die Abfrage und sieh dir ihren Plan mit &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; an.&lt;/li&gt;
&lt;li&gt;Fang mit einem B-tree auf den Spalten in &lt;code&gt;WHERE&lt;/code&gt; und &lt;code&gt;JOIN&lt;/code&gt; an, Gleichheitsspalten zuerst.&lt;/li&gt;
&lt;li&gt;Wenn nur ein kleiner Ausschnitt der Zeilen zählt, mach ihn partiell.&lt;/li&gt;
&lt;li&gt;Wenn die Tabelle riesig, append-only und zeitlich geordnet ist, probiere BRIN vor B-tree.&lt;/li&gt;
&lt;li&gt;Für Arrays, &lt;code&gt;jsonb&lt;/code&gt; und Textsuche nimm GIN und prüfe, ob die Treffer selektiv sind.&lt;/li&gt;
&lt;li&gt;Füge &lt;code&gt;INCLUDE&lt;/code&gt; nur für heiße Abfragen hinzu, die viele Zeilen liefern, und lass &lt;code&gt;VACUUM&lt;/code&gt; laufen.&lt;/li&gt;
&lt;li&gt;Baue mit &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; und prüfe &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; jedes Quartal.&lt;/li&gt;
&lt;li&gt;Lege für die referenzierende Spalte jedes Fremdschlüssels, nach der du joinst oder löschst, einen Index an: PostgreSQL erzeugt ihn nicht von selbst.&lt;/li&gt;
&lt;li&gt;Nimm für UUID-Primärschlüssel unter PostgreSQL 18 lieber UUIDv7 als zufällige UUIDv4.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Für Gewohnheiten auf Abfrageebene, die dazu passen, siehe &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/sql-query-optimierung-2026-schnellere-datenbank-performance" rel="noopener noreferrer"&gt;SQL Query Optimization 2026&lt;/a&gt;, und für Vektoren in Postgres &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/brauchst-du-eine-vektordatenbank-postgres-vs-dedizierte-2026" rel="noopener noreferrer"&gt;Brauchst du eine Vektordatenbank?&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Das Fazit
&lt;/h2&gt;

&lt;p&gt;Nimm standardmäßig B-tree und miss, bevor du zu etwas anderem greifst. Mach ihn partiell, wenn nur ein Ausschnitt der Zeilen zählt, nutze BRIN bei riesigen, zeitlich geordneten Tabellen und GIN, wenn du in Arrays und Dokumenten suchst und die Treffer selten sind. Sei vorsichtig mit dem Rest: Ein Hash-Index war nicht schneller, ein abdeckender Index war 4,4-mal größer für einen 1,4-fachen Gewinn, und jeder zusätzliche Index verlangsamte Inserts. Der beste Index ist der kleinste, der deine Abfrage beantwortet, und der zweitbeste ist der, den du gelöscht hast.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Indizes sind keine Geschwindigkeit zum Nulltarif. Jeder ist ein Tausch zwischen Lesezeit, Platte, Speicher und Schreibzeit, wähle den Typ also nach der Form der Abfrage und behalte nur die, die laut Statistik genutzt werden.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Postgres, PostgreSQL und das Slonik-Logo sind Marken oder eingetragene Marken der PostgreSQL Community Association of Canada.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ursprünglich veröffentlicht auf &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/postgresql-indizes-welcher-typ-wann-benchmark" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>postgresindex</category>
      <category>btree</category>
    </item>
    <item>
      <title>PostgreSQL Indexes: Which Type to Use (10M-Row Benchmark)</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Fri, 23 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/postgresql-indexes-which-type-to-use-10m-row-benchmark-4gi1</link>
      <guid>https://dev.to/aleksei_aleinikov/postgresql-indexes-which-type-to-use-10m-row-benchmark-4gi1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k5ac8qxau06pyn9o5d9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k5ac8qxau06pyn9o5d9.webp" alt="PostgreSQL Indexes: Which Type to Use (10M-Row Benchmark)" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every Postgres team eventually types &lt;code&gt;CREATE INDEX&lt;/code&gt; in a hurry, and most of them type the same one. B-tree is the right default, but it is not always the right answer. On a table with 10 million rows I found an index that was 3,000 times smaller and only 3 times slower, an index that did nothing at all, and a “covering” index that made queries faster until a routine &lt;code&gt;UPDATE&lt;/code&gt; took the advantage away.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;Part 1 of Postgres in Production&lt;/strong&gt; , a series about the decisions that matter once a database leaves the tutorial: indexes, VACUUM and bloat, connection pooling, partitioning and JSONB. Each part follows one rule: measure first, then explain. The numbers below come from PostgreSQL 18.6 and an open-source benchmark that you can run yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k5ac8qxau06pyn9o5d9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k5ac8qxau06pyn9o5d9.webp" alt="PostgreSQL index types compared on 10 million rows: B-tree, hash, BRIN and GIN, measured for speed, index size and write cost." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which PostgreSQL index type to use: the short answer
&lt;/h2&gt;

&lt;p&gt;A PostgreSQL index is a separate data structure that lets the database find rows without reading the whole table. The type decides which questions it can answer and how much space and write time it costs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your query&lt;/th&gt;
&lt;th&gt;Index to use&lt;/th&gt;
&lt;th&gt;Measured on 10 million rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WHERE user_id = 42&lt;/code&gt;, ranges, &lt;code&gt;ORDER BY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;B-tree&lt;/strong&gt; (the default)&lt;/td&gt;
&lt;td&gt;158 ms scan, 0.11 ms with the index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only a rare slice of rows matters (&lt;code&gt;status = 'failed'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Partial&lt;/strong&gt; B-tree&lt;/td&gt;
&lt;td&gt;1.8 MiB instead of 301 MiB, same speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Few columns, many lookups&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Covering&lt;/strong&gt; B-tree (&lt;code&gt;INCLUDE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0.109 to 0.078 ms, at 4.4 times the size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time range on append-only, ordered data&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;BRIN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;72 KiB instead of 214 MiB, 3 times slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contains: arrays, &lt;code&gt;jsonb&lt;/code&gt;, full-text, &lt;code&gt;LIKE '%x%'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;GIN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;360 ms to 2.8 ms for a rare tag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geometry, ranges, nearest neighbour&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;GiST&lt;/strong&gt; or &lt;strong&gt;SP-GiST&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;not benchmarked here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Equality only&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no faster than B-tree, 3 times bigger&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkculdaek6lqie11vsgrl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkculdaek6lqie11vsgrl.webp" alt="Decision map for PostgreSQL index types: from the shape of the query to B-tree, partial, covering, BRIN, GIN, GiST or hash." width="800" height="555"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Start from the query, not from the index type.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  How an index finds a row, and what a scan costs
&lt;/h2&gt;

&lt;p&gt;Without an index, PostgreSQL reads the table page by page and checks every row. The test table here is 1.6 GB, 208,376 pages of 8 KB, and a sequential scan counted every one of them. A B-tree index on the same table is a tree three to four levels deep, so finding one key touches a handful of pages instead: the lookup below read &lt;strong&gt;14 buffers instead of 208,376&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the whole idea, and everything else is a trade. A bigger index answers more kinds of questions but costs space, build time and write time. The planner chooses between a scan and an index by estimated cost, which is why an index that exists is not always used (more on that below). To see what actually happened, run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;. Since PostgreSQL 18, &lt;code&gt;BUFFERS&lt;/code&gt; is included automatically with &lt;code&gt;ANALYZE&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The benchmark: one table, 10 million rows, and what each index costs
&lt;/h2&gt;

&lt;p&gt;One &lt;code&gt;events&lt;/code&gt; table with a bigint primary key, &lt;code&gt;user_id&lt;/code&gt; (random, about 10 rows per user), &lt;code&gt;status&lt;/code&gt; (70% &lt;code&gt;done&lt;/code&gt;, 20% &lt;code&gt;active&lt;/code&gt;, 9% &lt;code&gt;pending&lt;/code&gt;, 0.85% &lt;code&gt;failed&lt;/code&gt;, 0.15% &lt;code&gt;archived&lt;/code&gt;), &lt;code&gt;created_at&lt;/code&gt; (one row every 3 seconds, so it grows with &lt;code&gt;id&lt;/code&gt;), a &lt;code&gt;tags text[]&lt;/code&gt; column and a &lt;code&gt;payload jsonb&lt;/code&gt; column. The table is 1.6 GB and the primary key 214 MiB. Data comes from a fixed random seed, so every run produces the same rows.&lt;/p&gt;

&lt;p&gt;Each query ran once as a warm-up and then three times, and the table shows the median. Point and range queries were repeated for several parameter values. &lt;strong&gt;Every measured query was also run with index scans disabled, and the answers had to match&lt;/strong&gt; , otherwise the script stops. PostgreSQL 18.6, warm cache, &lt;code&gt;autovacuum&lt;/code&gt; off so the visibility map stays under control (a production database should not run like that).&lt;/p&gt;

&lt;p&gt;Note&lt;/p&gt;
&lt;p&gt;Reproduce it in three commands: &lt;code&gt;git clone https://github.com/LF3551/postgres-index-benchmark&lt;/code&gt;, then &lt;code&gt;npm run up&lt;/code&gt; and &lt;code&gt;npm run all&lt;/code&gt;. The &lt;a href="https://github.com/LF3551/postgres-index-benchmark" rel="noopener noreferrer"&gt;repository&lt;/a&gt; starts a pinned PostgreSQL 18.6 in Docker, loads the same data, runs every experiment below and prints the tables. You can also point it at your own server with the &lt;code&gt;PG*&lt;/code&gt; variables. Single-machine numbers are a sanity check, not a leaderboard: compare ratios, sizes and plans, not milliseconds.&lt;/p&gt;
&lt;h2&gt;
  
  
  B-tree: the default for a reason
&lt;/h2&gt;

&lt;p&gt;B-tree is a balanced tree of sorted keys. Because the keys are sorted, one structure answers &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;BETWEEN&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt; and &lt;code&gt;IS NULL&lt;/code&gt;, and it is what &lt;code&gt;CREATE INDEX&lt;/code&gt; builds unless you ask for something else.&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_user&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;424242&lt;/span&gt;&lt;span class="p"&gt;;&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;Lookup on &lt;code&gt;user_id&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Buffers&lt;/th&gt;
&lt;th&gt;Index size&lt;/th&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No index (sequential scan)&lt;/td&gt;
&lt;td&gt;158 ms&lt;/td&gt;
&lt;td&gt;208,376&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B-tree&lt;/td&gt;
&lt;td&gt;0.112 ms&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;88.5 MiB&lt;/td&gt;
&lt;td&gt;1.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is about 1,400 times faster for one extra index of 88.5 MiB, built in 1.4 seconds on a 1.6 GB table. The index is small because B-tree stores each duplicate key once and keeps a list of row pointers (deduplication, since PostgreSQL 13). The same index on a time column, where every key is unique, is 214 MiB.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a column appears in &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;JOIN&lt;/code&gt; or &lt;code&gt;ORDER BY&lt;/code&gt; of a query that runs often and returns few rows, a B-tree is almost always the answer. The other types exist for what B-tree does badly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Hash index: no better than B-tree
&lt;/h2&gt;

&lt;p&gt;A hash index stores a hash of the key and supports only equality. The theory says it should be faster for point lookups. In this test it was not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;user_id = ?&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Lookup&lt;/th&gt;
&lt;th&gt;Index size&lt;/th&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;B-tree&lt;/td&gt;
&lt;td&gt;0.112 ms&lt;/td&gt;
&lt;td&gt;88.5 MiB&lt;/td&gt;
&lt;td&gt;1.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash&lt;/td&gt;
&lt;td&gt;0.115 ms&lt;/td&gt;
&lt;td&gt;267.6 MiB&lt;/td&gt;
&lt;td&gt;13.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same speed, three times the space, nine times the build time, and no ranges or sorting. Hash indexes have been crash-safe since PostgreSQL 10, so they are not dangerous, but with duplicate-heavy keys they have no advantage over a B-tree that deduplicates. This is one data set, not a law: if your keys are long strings, hashing them may save space, so measure before dismissing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  BRIN: a 72 KiB index for time-ordered data
&lt;/h2&gt;

&lt;p&gt;A BRIN (block range index) stores a summary, normally the minimum and maximum value, for each range of table pages. A query skips every range whose summary cannot match. It is tiny, but it works only if the values in the column follow the physical order of the table, and an append-only &lt;code&gt;created_at&lt;/code&gt; does exactly that: here &lt;code&gt;pg_stats&lt;/code&gt; reports a correlation of 1.0 for &lt;code&gt;created_at&lt;/code&gt; and about 0 for &lt;code&gt;user_id&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_ts_brin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;The query: one day of events, 28,800 rows out of 10 million.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;One day by &lt;code&gt;created_at&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Buffers&lt;/th&gt;
&lt;th&gt;Index size&lt;/th&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No index&lt;/td&gt;
&lt;td&gt;635 ms&lt;/td&gt;
&lt;td&gt;208,376&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B-tree&lt;/td&gt;
&lt;td&gt;3.0 ms&lt;/td&gt;
&lt;td&gt;690&lt;/td&gt;
&lt;td&gt;214.2 MiB&lt;/td&gt;
&lt;td&gt;1.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BRIN (default range of 128 pages)&lt;/td&gt;
&lt;td&gt;9.1 ms&lt;/td&gt;
&lt;td&gt;785&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;72 KiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BRIN (&lt;code&gt;pages_per_range = 32&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;8.2 ms&lt;/td&gt;
&lt;td&gt;683&lt;/td&gt;
&lt;td&gt;232 KiB&lt;/td&gt;
&lt;td&gt;0.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;B-tree is three times faster, and BRIN is &lt;strong&gt;about 3,000 times smaller&lt;/strong&gt;. For a table that grows by billions of rows, that is the difference between an index that fits in memory and one that does not, and a BRIN index can be rebuilt in a fraction of a second. If your queries are always “the last day” or “last month”, BRIN is the cheap choice. If one lookup must return in under a millisecond, use B-tree. A smaller &lt;code&gt;pages_per_range&lt;/code&gt; makes the index slightly larger and sharper.&lt;/p&gt;

&lt;p&gt;Warning&lt;/p&gt;

&lt;p&gt;BRIN on a column that is not physically ordered is useless: it cannot skip any block range and the planner will not use it. Check &lt;code&gt;SELECT attname, correlation FROM pg_stats WHERE tablename = 'events';&lt;/code&gt; first. If rows are updated and moved around, correlation decays and BRIN decays with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partial indexes: index only the rows that matter
&lt;/h2&gt;

&lt;p&gt;A partial index has a &lt;code&gt;WHERE&lt;/code&gt; clause and contains only the rows that match. It is the cheapest optimisation in this article because many queries care about a small, well-defined slice: unprocessed jobs, failed payments, active sessions.&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_failed_ts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&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;created_at&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'failed'&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;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&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;Newest 50 failed events&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Index size&lt;/th&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No index&lt;/td&gt;
&lt;td&gt;177 ms&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full index &lt;code&gt;(status, created_at)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.138 ms&lt;/td&gt;
&lt;td&gt;301.0 MiB&lt;/td&gt;
&lt;td&gt;3.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial index &lt;code&gt;WHERE status = 'failed'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.125 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.8 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Failed events are 0.85% of the rows. The partial index answers the query as fast as the full one and is about &lt;strong&gt;160 times smaller&lt;/strong&gt; , builds in a fraction of the time, and costs almost nothing on writes because 99% of inserts never touch it. The rule: the query’s &lt;code&gt;WHERE&lt;/code&gt; must imply the index’s &lt;code&gt;WHERE&lt;/code&gt;, so write the condition the same way in both. A parameter such as &lt;code&gt;status = $1&lt;/code&gt; can keep the planner from proving the match in a generic plan, so confirm with &lt;code&gt;EXPLAIN&lt;/code&gt; that the partial index is used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Covering indexes: faster, but only on a clean table
&lt;/h2&gt;

&lt;p&gt;An index-only scan answers a query from the index alone, without visiting the table. &lt;code&gt;INCLUDE&lt;/code&gt; (PostgreSQL 11 and later) adds extra columns to the index for exactly that purpose.&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_user_cov&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;424242&lt;/span&gt;&lt;span class="p"&gt;;&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;Covering index vs plain B-tree&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Buffers&lt;/th&gt;
&lt;th&gt;Index size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain B-tree on &lt;code&gt;user_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.109 ms&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;88.5 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Covering, table just vacuumed&lt;/td&gt;
&lt;td&gt;0.078 ms&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;386.7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Covering, after updating 1% of the rows&lt;/td&gt;
&lt;td&gt;0.109 ms&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;386.7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Covering, after &lt;code&gt;VACUUM&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.079 ms&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;386.7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two lessons here. First, the gain is real but modest (1.4 times) while the index is &lt;strong&gt;4.4 times bigger&lt;/strong&gt; : for a query that returns ten rows, a plain B-tree is already good. In theory a covering index pays off more when a query returns many rows from one index range and every heap fetch counts; measure that on your own data. Second, an index-only scan still checks the visibility map to see whether a page is all-visible, and an &lt;code&gt;UPDATE&lt;/code&gt; clears those bits. After touching 1% of the rows, spread over every page, the advantage was gone until &lt;code&gt;VACUUM&lt;/code&gt; ran again. Autovacuum normally does this for you; the benchmark turns it off to make the effect visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multicolumn indexes and skip scan in PostgreSQL 18
&lt;/h2&gt;

&lt;p&gt;A multicolumn B-tree sorts by the first column, then the second within each first value, so the order of the columns matters. Before PostgreSQL 18, a query that filtered only on the second column could not use such an index efficiently. PostgreSQL 18 adds &lt;strong&gt;skip scan&lt;/strong&gt; , which jumps through the distinct values of the leading column.&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_status_ts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&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="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- no filter on status at all:&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;events&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2025-02-12'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2025-02-12 01:00'&lt;/span&gt;&lt;span class="p"&gt;;&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;One hour by &lt;code&gt;created_at&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Buffers&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No index&lt;/td&gt;
&lt;td&gt;233 ms&lt;/td&gt;
&lt;td&gt;210,460&lt;/td&gt;
&lt;td&gt;Seq Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;(status, created_at)&lt;/code&gt; index&lt;/td&gt;
&lt;td&gt;0.246 ms&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;Index Only Scan, &lt;strong&gt;Index Searches: 11&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; now reports &lt;code&gt;Index Searches&lt;/code&gt;: 11 descents through the index instead of one, which is still tiny. Skip scan makes an index you already have more useful. It works when the skipped column has few distinct values (five statuses here). With millions of distinct leading values it degrades, so it does not replace a dedicated index on the column you filter by.&lt;/p&gt;

&lt;h2&gt;
  
  
  GIN: when a row contains many values
&lt;/h2&gt;

&lt;p&gt;A GIN (generalized inverted index) maps each element to the rows that contain it, like the index at the back of a book. It is the index for arrays, &lt;code&gt;jsonb&lt;/code&gt;, full-text search (&lt;code&gt;tsvector&lt;/code&gt;) and, with the &lt;code&gt;pg_trgm&lt;/code&gt; extension, &lt;code&gt;LIKE '%text%'&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_tags&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_payload&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb_path_ops&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;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rare'&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;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"beta": true}'&lt;/span&gt;&lt;span class="p"&gt;;&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;Query&lt;/th&gt;
&lt;th&gt;Matches&lt;/th&gt;
&lt;th&gt;No index&lt;/th&gt;
&lt;th&gt;GIN&lt;/th&gt;
&lt;th&gt;Index size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Array, rare tag&lt;/td&gt;
&lt;td&gt;1,042 rows&lt;/td&gt;
&lt;td&gt;360 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2.8 ms&lt;/strong&gt; (129x)&lt;/td&gt;
&lt;td&gt;39.1 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Array, common tag&lt;/td&gt;
&lt;td&gt;99,778 rows (1%)&lt;/td&gt;
&lt;td&gt;364 ms&lt;/td&gt;
&lt;td&gt;113 ms (3.2x)&lt;/td&gt;
&lt;td&gt;39.1 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonb @&amp;gt;&lt;/code&gt; rare flag&lt;/td&gt;
&lt;td&gt;5,064 rows&lt;/td&gt;
&lt;td&gt;353 ms&lt;/td&gt;
&lt;td&gt;34.1 ms (10x) with default ops&lt;/td&gt;
&lt;td&gt;86.0 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonb @&amp;gt;&lt;/code&gt; rare flag&lt;/td&gt;
&lt;td&gt;5,064 rows&lt;/td&gt;
&lt;td&gt;353 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;9.9 ms&lt;/strong&gt; (36x) with &lt;code&gt;jsonb_path_ops&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;44.5 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonb @&amp;gt;&lt;/code&gt; country and plan&lt;/td&gt;
&lt;td&gt;250,512 rows (2.5%)&lt;/td&gt;
&lt;td&gt;399 ms&lt;/td&gt;
&lt;td&gt;347 ms (1.1x) default, 262 ms (1.5x) &lt;code&gt;jsonb_path_ops&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;86.0 / 44.5 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9o3yjn8sq1yp6a3d6fli.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9o3yjn8sq1yp6a3d6fli.webp" alt="PostgreSQL index size against lookup time on 10 million rows: BRIN is 72 KiB, a partial index is 1.8 MiB, B-tree is 88 to 214 MiB, a hash index is 268 MiB and GIN is 39 to 86 MiB." width="800" height="520"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Index size on a log scale. The cheapest index is rarely the slowest.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three things to take from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Selectivity decides everything.&lt;/strong&gt; GIN shines when the match is rare (129 times faster) and barely helps when the match is 2.5% of the table, because the heap pages still have to be read. An unselective containment query is a data-modelling signal, not an index problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For &lt;code&gt;jsonb&lt;/code&gt;, try &lt;code&gt;jsonb_path_ops&lt;/code&gt;.&lt;/strong&gt; It is about half the size and faster on &lt;code&gt;@&amp;gt;&lt;/code&gt; containment, at the cost of not supporting the key-existence operators (&lt;code&gt;?&lt;/code&gt;, &lt;code&gt;?|&lt;/code&gt;, &lt;code&gt;?&amp;amp;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel builds are new.&lt;/strong&gt; PostgreSQL 18 can build a GIN index with parallel workers: 3.8 s with four workers against 14.4 s serial on the &lt;code&gt;tags&lt;/code&gt; index, 3.8 times faster, which matters when you rebuild on a large table.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  GiST and SP-GiST: for shapes and ranges
&lt;/h2&gt;

&lt;p&gt;I did not benchmark these on this data, because the test table has no geometry or ranges, and inventing a benchmark would only measure my schema. &lt;strong&gt;GiST&lt;/strong&gt; is a framework for balanced-tree indexes over data that is not linearly ordered: geometric types (PostGIS uses it), range types, and nearest-neighbour search (&lt;code&gt;ORDER BY location &amp;lt;-&amp;gt; point&lt;/code&gt;). It also backs exclusion constraints such as “no two bookings of the same room overlap”. &lt;strong&gt;SP-GiST&lt;/strong&gt; supports space-partitioned structures like quad-trees and radix tries, useful for unbalanced data such as IP addresses or phone-number prefixes. If your query uses &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; or &lt;code&gt;@&amp;gt;&lt;/code&gt; on shapes or ranges, start here.&lt;/p&gt;
&lt;h2&gt;
  
  
  When the planner ignores your index
&lt;/h2&gt;

&lt;p&gt;The most common index mistake is not choosing the wrong type, it is writing a query the index cannot serve. Two cases measured on the same table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;One day by &lt;code&gt;created_at&lt;/code&gt; or a status filter&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at &amp;gt;= ... AND created_at &amp;lt; ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;3.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at::date = '2025-04-01'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Seq Scan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;329 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, with an expression index on &lt;code&gt;(created_at AT TIME ZONE 'UTC')::date&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;2.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;status = 'done'&lt;/code&gt; (70% of rows), index on &lt;code&gt;status&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Seq Scan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;263 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;status = 'archived'&lt;/code&gt; (0.15% of rows), same index&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;20.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wrapping an indexed column in a function or a cast makes the index unusable, so rewrite the condition as a range, or index the expression. Note that &lt;code&gt;timestamptz::date&lt;/code&gt; depends on the session time zone and so cannot be indexed directly: the expression index uses &lt;code&gt;AT TIME ZONE 'UTC'&lt;/code&gt;. And when a query matches most of the table, the planner is right to skip the index: reading 70% of a table through an index is slower than a sequential scan.&lt;/p&gt;
&lt;h2&gt;
  
  
  The write tax: every index costs on every insert
&lt;/h2&gt;

&lt;p&gt;Reads are only half of the bill. Every insert must add an entry to every index on the table. I inserted 1 million rows into an empty table with a primary key and then 0, 1, 3 and 5 more indexes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secondary indexes&lt;/th&gt;
&lt;th&gt;Insert 1M rows&lt;/th&gt;
&lt;th&gt;Compared with primary key only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1.9 s&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 (B-tree on &lt;code&gt;user_id&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;4.3 s&lt;/td&gt;
&lt;td&gt;2.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 (adds &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;(status, created_at)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;5.8 s&lt;/td&gt;
&lt;td&gt;3.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 (adds GIN on &lt;code&gt;tags&lt;/code&gt; and on &lt;code&gt;payload&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;12.1 s&lt;/td&gt;
&lt;td&gt;6.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhooj9x03ivrant9fh2a.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhooj9x03ivrant9fh2a.webp" alt="PostgreSQL write cost: inserting 1 million rows takes 1.9 s with a primary key only and 12.1 s with five more indexes, two of them GIN." width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The first extra index and the GIN indexes cost the most.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The jump from 3 to 5 indexes, more than doubling the time, is the two GIN indexes: they are the most expensive to maintain, and by default PostgreSQL buffers GIN updates in a pending list to soften that. Indexes also slow &lt;code&gt;UPDATE&lt;/code&gt; (when an indexed column changes) and &lt;code&gt;VACUUM&lt;/code&gt;, and take memory and disk. An index you do not use is pure cost.&lt;/p&gt;
&lt;h2&gt;
  
  
  Random UUID keys: a hidden index cost
&lt;/h2&gt;

&lt;p&gt;A primary key is an index too, and its type decides how the B-tree grows. A random UUIDv4 lands anywhere in the tree, so inserts touch pages all over it and split them. A UUIDv7 starts with a timestamp, so new keys arrive in order at the right edge, like a sequence. PostgreSQL 18 has a built-in &lt;code&gt;uuidv7()&lt;/code&gt; for this. I inserted 5 million rows into a table with each kind of primary key:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primary key&lt;/th&gt;
&lt;th&gt;Insert 5M rows&lt;/th&gt;
&lt;th&gt;Primary key size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UUIDv4 (&lt;code&gt;gen_random_uuid()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;12.9 s&lt;/td&gt;
&lt;td&gt;191 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UUIDv7 (&lt;code&gt;uuidv7()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;9.6 s&lt;/td&gt;
&lt;td&gt;150 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The time-ordered key inserted 1.3 times faster and produced an index about a fifth smaller. These tables fit in memory, so the gap is likely to widen once the index no longer does, because random keys then miss the cache on every insert. UUIDv7 also reveals the creation time inside the id, which matters if the id is public. A bigint sequence is smaller still (8 bytes against 16).&lt;/p&gt;
&lt;h2&gt;
  
  
  How to find unused and missing indexes
&lt;/h2&gt;

&lt;p&gt;Do not guess, ask the statistics. Indexes that were never scanned since the last statistics reset (check &lt;code&gt;pg_stat_database.stats_reset&lt;/code&gt; to see how long the counters have been collecting):&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idx_scan&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;

&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idx_scan&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;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indisunique&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indisprimary&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;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&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;Skip unique and primary-key indexes, they enforce constraints. Observe over a full business cycle (month-end jobs), and remember that each replica keeps its own counters. For missing indexes, look at tables with many rows read by sequential scans:&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq_tup_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_live_tup&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;seq_scan&lt;/span&gt; &lt;span class="o"&gt;&amp;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="n"&gt;seq_tup_read&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Then find the queries behind them with &lt;code&gt;pg_stat_statements&lt;/code&gt; and check the plan with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;. On a live system, always build with &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;, which does not block writes (it cannot run inside a transaction, and a failed build leaves an &lt;code&gt;INVALID&lt;/code&gt; index that you must drop), and remove an index with &lt;code&gt;DROP INDEX CONCURRENTLY&lt;/code&gt;. An index that has bloated after many updates and deletes can be rebuilt without blocking writes with &lt;code&gt;REINDEX INDEX CONCURRENTLY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in PostgreSQL 18 and 19 for indexes
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 18, the current major release, brought four index-related changes used in this article: skip scan for multicolumn B-tree indexes, parallel GIN builds, &lt;code&gt;Index Searches&lt;/code&gt; plus automatic &lt;code&gt;BUFFERS&lt;/code&gt; in &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, and the built-in &lt;code&gt;uuidv7()&lt;/code&gt; for index-friendly keys. It also added the new asynchronous I/O subsystem, which speeds up the sequential and bitmap scans that these indexes are competing with. PostgreSQL 19 is at the release candidate stage, and PostgreSQL 14 reaches end of life on 12 November 2026, so now is a good moment to check which version your own database runs. The &lt;a href="https://www.postgresql.org/docs/18/release-18.html" rel="noopener noreferrer"&gt;official release notes&lt;/a&gt; have the full list, and if you are still deciding on a database, &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/postgres-vs-mysql-2026" rel="noopener noreferrer"&gt;Postgres vs MySQL in 2026&lt;/a&gt; compares the two.&lt;/p&gt;

&lt;p&gt;Note&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in Postgres in Production:&lt;/strong&gt; VACUUM and bloat (why a table grows although you delete from it), connection pooling, partitioning and JSONB in depth. Indexes are the first lever for performance, and the next parts cover what keeps them fast after a year in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write the query first and look at its plan with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Start with a B-tree on the columns in &lt;code&gt;WHERE&lt;/code&gt; and &lt;code&gt;JOIN&lt;/code&gt;, equality columns first.&lt;/li&gt;
&lt;li&gt;If only a small slice of rows matters, make it partial.&lt;/li&gt;
&lt;li&gt;If the table is huge, append-only and time-ordered, try BRIN before B-tree.&lt;/li&gt;
&lt;li&gt;For arrays, &lt;code&gt;jsonb&lt;/code&gt; and text search, use GIN, and check that the matches are selective.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;INCLUDE&lt;/code&gt; only for hot queries returning many rows, and keep &lt;code&gt;VACUUM&lt;/code&gt; running.&lt;/li&gt;
&lt;li&gt;Build with &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;, and review &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; every quarter.&lt;/li&gt;
&lt;li&gt;Index the referencing column of every foreign key you join or delete by: PostgreSQL does not create it for you.&lt;/li&gt;
&lt;li&gt;For UUID primary keys, prefer UUIDv7 over random UUIDv4 on PostgreSQL 18.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For query-level habits that go together with this, see &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/sql-query-optimization-2026-faster-database-performance" rel="noopener noreferrer"&gt;SQL Query Optimization in 2026&lt;/a&gt;, and for the case of vectors inside Postgres, &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/do-you-need-a-vector-database-postgres-vs-dedicated-2026" rel="noopener noreferrer"&gt;Do You Need a Vector Database?&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Use B-tree by default and measure before reaching for anything else. Make it partial when only a slice of rows matters, use BRIN when the table is huge and time-ordered, and use GIN when you search inside arrays and documents and the matches are rare. Be careful with the rest: a hash index was no faster, a covering index was 4.4 times bigger for a 1.4 times gain, and every extra index slowed inserts. The best index is the smallest one that answers your query, and the second best is the one you deleted.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Indexes are not free speed. Each one is a trade between read time, disk, memory and write time, so choose the type from the shape of the query and keep only the ones the statistics show are used.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Postgres, PostgreSQL and the Slonik Logo are trademarks or registered trademarks of the PostgreSQL Community Association of Canada.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/postgres-index-types-which-to-use-btree-gin-brin-benchmark" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>postgresindex</category>
      <category>btree</category>
    </item>
    <item>
      <title>AI Gateway (LLM Gateway) Explained: What It Does, When You Need One and What It Costs</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/ai-gateway-llm-gateway-explained-what-it-does-when-you-need-one-and-what-it-costs-53gp</link>
      <guid>https://dev.to/aleksei_aleinikov/ai-gateway-llm-gateway-explained-what-it-does-when-you-need-one-and-what-it-costs-53gp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyiyul2rtiwb02t0i4ik.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyiyul2rtiwb02t0i4ik.webp" alt="AI Gateway (LLM Gateway) Explained: What It Does, When You Need One and What It Costs" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first LLM feature is easy. You put an API key in an environment variable, call the provider’s SDK and ship. The trouble starts with the second and third: three provider keys copied into five services, nobody able to say which team spent the money last Tuesday, and one provider’s outage taking the whole product down with it. The usual answer to all three is the same piece of infrastructure, and it goes by two names: &lt;strong&gt;AI gateway&lt;/strong&gt; and &lt;strong&gt;LLM gateway&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This guide explains what an AI gateway is, what sits inside one, how it differs from the API gateway you already run, when it is worth adding, what changes in your code, and what breaks once real traffic runs through it. It also measures the part that vendors’ landing pages skip: what the extra hop costs. Four gateways, one mock model, the same CPU budget for each, and a repository you can run yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyiyul2rtiwb02t0i4ik.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyiyul2rtiwb02t0i4ik.webp" alt="AI gateway explained: requests from apps run through one signal box that holds the keys, limits, cache, routing and logs, and switches to another model provider when one returns 429." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an AI gateway?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An AI gateway, also called an LLM gateway, is a proxy between your applications and the model providers they call.&lt;/strong&gt; Every app sends its requests to one endpoint, usually OpenAI-compatible, with a key the gateway issued. The gateway checks who is calling, enforces request and token limits and budgets, picks the provider and model, retries or falls back when a provider fails, and logs tokens, cost and latency for every call. The real provider keys never leave it.&lt;/p&gt;

&lt;p&gt;That is the whole idea: one door between your code and every model. The rest of this article is about what happens at that door.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI gateway vs LLM gateway vs LLM proxy vs LLM router
&lt;/h2&gt;

&lt;p&gt;The vocabulary is messier than the concept, so here are the terms you will meet in docs and vendor pages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it means in practice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The full thing: auth, limits, budgets, routing, fallback, caching, guardrails, logs. Covers chat and usually also embeddings, images, audio and MCP tool calls.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The same product under another name, with the emphasis on text models. Used interchangeably with “AI gateway”.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM proxy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The minimal version: one OpenAI-compatible endpoint that forwards and translates requests to many providers. Often the starting point of a gateway.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM router&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The part that decides which model answers: by alias, price, latency, load or the content of the prompt. Sometimes a feature of a gateway, sometimes a separate service.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The general-purpose proxy for HTTP APIs (Kong, Envoy, NGINX, cloud API gateways). Some of them grow AI plugins and become AI gateways too.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What sits inside an AI gateway
&lt;/h2&gt;

&lt;p&gt;Every gateway on the market is a combination of the same building blocks. Some do all of them, some only a few:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco5n4rd0u9hkutabo3am.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco5n4rd0u9hkutabo3am.webp" alt="Inside an AI gateway: requests pass auth, limits, cache and guardrails, a router picks the provider and falls back on 429, responses pass output checks, cost accounting, cache and logs. A control plane holds teams, keys, budgets and provider secrets." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Request path left to right, response path back. The provider keys live in the control plane and never reach an app.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth and virtual keys.&lt;/strong&gt; Apps get keys issued by the gateway, one per team, app or user, and the gateway swaps them for the real provider key on the way out. Revoking a leaked key no longer means rotating your OpenAI key in twelve places.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits.&lt;/strong&gt; Not just requests per second: &lt;strong&gt;tokens per minute&lt;/strong&gt; and &lt;strong&gt;tokens per day&lt;/strong&gt; , because one request can cost 200 tokens or 200,000. The algorithms are the same as for any API, and so are the mistakes; I wrote about those in &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/rate-limiting-in-practice-algorithms-headers-2026" rel="noopener noreferrer"&gt;rate limiting in practice&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budgets.&lt;/strong&gt; Spend caps per team, key or project, counted in money. The gateway knows the price of every model, so it can stop a runaway agent at a fixed amount instead of at the end of the month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing, retries and fallback.&lt;/strong&gt; Model aliases (&lt;code&gt;chat-default&lt;/code&gt; maps to whatever you decide this week), load balancing across keys or regions, retries with backoff, and fallback to another provider when the first returns &lt;code&gt;429&lt;/code&gt; or &lt;code&gt;5xx&lt;/code&gt;. Because providers have different APIs, the gateway translates request and response formats on the fly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching.&lt;/strong&gt; Exact-match caching of identical prompts is cheap and safe. Semantic caching answers prompts that are merely similar, which saves more and is wrong more often.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails.&lt;/strong&gt; Checks on prompts and answers: personal data, secrets, banned topics, prompt-injection patterns. They help, but they are not a security boundary on their own; why, is in &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/security/prompt-injection-defense-2026-lethal-trifecta-test" rel="noopener noreferrer"&gt;prompt injection defence&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability and cost.&lt;/strong&gt; Every call logged with model, tokens, latency, cost, team and key. This is often the first reason a company installs a gateway: finance wants the report.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gateway also gives the same treatment to self-hosted models. A &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/ai/vllm-vs-ollama-inference-server-2026" rel="noopener noreferrer"&gt;vLLM or Ollama server&lt;/a&gt; becomes one more provider behind the same endpoint, with the same keys, limits and logs.&lt;/p&gt;
&lt;h2&gt;
  
  
  AI gateway vs API gateway
&lt;/h2&gt;

&lt;p&gt;If you already run an API gateway, the obvious question is why it is not enough. It can be, partly. Kong is literally an API gateway with an AI plugin set. But a plain API gateway misses the parts that make LLM traffic different:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;API gateway&lt;/th&gt;
&lt;th&gt;AI gateway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Unit of limits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;requests per second&lt;/td&gt;
&lt;td&gt;requests &lt;strong&gt;and&lt;/strong&gt; tokens per minute, per key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;not its concern&lt;/td&gt;
&lt;td&gt;price per model, budgets, spend per team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Typical response&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;small JSON in milliseconds&lt;/td&gt;
&lt;td&gt;long text, often streamed for seconds or minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Failover&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;another replica of the same service&lt;/td&gt;
&lt;td&gt;another vendor with a different API and model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache key&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;URL and headers&lt;/td&gt;
&lt;td&gt;the prompt, optionally by meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Content checks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;schema, size, injection patterns&lt;/td&gt;
&lt;td&gt;personal data, secrets, prompt injection, policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Logs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;status, latency, bytes&lt;/td&gt;
&lt;td&gt;model, prompt and completion tokens, cost, user&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The short version: an API gateway counts requests, an AI gateway counts tokens and money.&lt;/p&gt;
&lt;h2&gt;
  
  
  Do you need an AI gateway?
&lt;/h2&gt;

&lt;p&gt;Not always. A gateway is one more service to run, secure and keep up to date, and it sits on the hot path of every model call. For a single app on a single provider it mostly adds a hop. Three questions decide it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F58yin38ghi9x2qt0i154.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F58yin38ghi9x2qt0i154.webp" alt="Decision tree: one app with one provider can skip a gateway; without shared keys or budgets a client library is enough; if prompts and logs must stay in your network, self-host a gateway; otherwise use a managed one." width="800" height="467"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Most teams cross question 2 sooner than they expect: the second team that wants the same OpenAI key is the moment.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The middle option deserves a word. A &lt;strong&gt;client library&lt;/strong&gt; that wraps many providers (the LiteLLM Python SDK, the Vercel AI SDK and others) gives you one API, retries and fallback inside your process, without a network hop. What it cannot give you is a central place for keys, budgets and logs across teams. That is the line where a library stops and a gateway starts.&lt;/p&gt;

&lt;p&gt;And writing your own? A proxy that forwards OpenAI-format requests is an afternoon of work. The months go into what comes after: translating between provider APIs that keep changing (new endpoints, reasoning parameters, tool formats), counting tokens correctly on streams, keeping price tables current, and every edge case of server-sent events. Unless the gateway is your product, start from an existing one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Putting one in: what changes in your code
&lt;/h2&gt;

&lt;p&gt;Less than you might expect. Most gateways speak the OpenAI API, so the application change is two lines, the base URL and the key:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;app.py: the only change on the client side&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://llm-gateway.internal/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# the gateway, not the provider
&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LLM_GATEWAY_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;# a virtual key for this app
&lt;/span&gt;
    &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# the gateway retries; see below
&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&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="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat-default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# an alias that the gateway resolves
&lt;/span&gt;
    &lt;span class="n"&gt;messages&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;role&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;user&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;content&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;Summarise this support ticket.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;The model name is now an alias. Which provider and model sit behind &lt;code&gt;chat-default&lt;/code&gt;, and what happens when it fails, lives in the gateway. In the LiteLLM proxy it looks like this:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;config.yaml (LiteLLM proxy)&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="na"&gt;model_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chat-default&lt;/span&gt;

    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropic/claude-sonnet-5-5&lt;/span&gt;

      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/ANTHROPIC_API_KEY&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chat-backup&lt;/span&gt;

    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openai/gpt-6.1-sol&lt;/span&gt;

      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/OPENAI_API_KEY&lt;/span&gt;

&lt;span class="na"&gt;litellm_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

  &lt;span class="na"&gt;num_retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;

  &lt;span class="na"&gt;fallbacks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat-default"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat-backup"&lt;/span&gt;&lt;span class="pi"&gt;]}]&lt;/span&gt;

&lt;span class="na"&gt;general_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

  &lt;span class="na"&gt;master_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/LITELLM_MASTER_KEY&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Kong, Portkey and the managed gateways write it differently, but the shape is the same: aliases on the outside, providers and keys on the inside. Changing the default model becomes a config change instead of a redeploy of every service.&lt;/p&gt;

&lt;p&gt;A rollout order that avoids surprises:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Route and observe, change nothing.&lt;/strong&gt; Point one service at the gateway with generous limits and only logging on. You get the first honest cost report and a latency baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issue virtual keys, then revoke the direct ones.&lt;/strong&gt; One key per app or team. A provider key still sitting in some &lt;code&gt;.env&lt;/code&gt; file makes the gateway optional, and optional controls get bypassed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run budgets and limits in report-only mode first.&lt;/strong&gt; Look at who would have been blocked last week before you block anyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add fallback last, and test it.&lt;/strong&gt; Run your evals against the fallback model before it ever serves a user. The production section below explains why.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The benchmark: what does the extra hop cost?
&lt;/h2&gt;

&lt;p&gt;An AI gateway is one more network hop and one more process on every model call, so the fair question is what that costs. Vendor pages answer with a single number measured on their own hardware. I wanted all of them on the same terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup.&lt;/strong&gt; A small Go server plays the model: it speaks the OpenAI chat API and answers instantly, with a 1.4 KB reply or a stream of 40 chunks. Because the model takes no time, every millisecond above a direct call is the gateway’s own work. Each gateway runs alone in Docker with the same budget of &lt;strong&gt;2 CPUs and 2 GB&lt;/strong&gt; , default settings, no caching, no guardrails and no logging database. A plain &lt;strong&gt;nginx&lt;/strong&gt; reverse proxy sets the floor: the cost of one extra hop that does nothing. The load is open-loop, so requests keep arriving on schedule even when a gateway slows down, and a request only counts if the answer is complete and valid. Chat runs at 100, 500, 1,000 and 2,000 requests per second, streaming at 100 and 500; each scenario runs 3 rounds of 20 seconds after a warm-up, and the tables show the median round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gj2hnzo6irl0qhi7qgn.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gj2hnzo6irl0qhi7qgn.webp" alt="Benchmark results: added time to a chat reply and to the first streamed token at 100 requests per second, and the highest rate each gateway kept up with. nginx, Kong and Portkey passed 2,000 chat requests per second; LiteLLM kept up to about 400." width="800" height="471"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Bars: median time added over a direct call at 100 rps. Right: the highest tested rate with every answer valid and p99 under 100 ms.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gateway (version)&lt;/th&gt;
&lt;th&gt;Added to chat reply, p50 / p99&lt;/th&gt;
&lt;th&gt;Added to first streamed token, p50&lt;/th&gt;
&lt;th&gt;Chat, 2,000 rps&lt;/th&gt;
&lt;th&gt;Stream, 500 rps&lt;/th&gt;
&lt;th&gt;Peak CPU / memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nginx 1.29 (floor)&lt;/td&gt;
&lt;td&gt;+0.31 / +1.84 ms&lt;/td&gt;
&lt;td&gt;+0.48 ms&lt;/td&gt;
&lt;td&gt;100% valid&lt;/td&gt;
&lt;td&gt;100% valid&lt;/td&gt;
&lt;td&gt;18% / 10 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kong 3.9, &lt;code&gt;ai-proxy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;+0.85 / +2.55 ms&lt;/td&gt;
&lt;td&gt;+1.07 ms&lt;/td&gt;
&lt;td&gt;100% valid&lt;/td&gt;
&lt;td&gt;100% valid&lt;/td&gt;
&lt;td&gt;32% / 300 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portkey 1.15&lt;/td&gt;
&lt;td&gt;+1.73 / +1.40 ms&lt;/td&gt;
&lt;td&gt;+27.9 ms&lt;/td&gt;
&lt;td&gt;100% valid&lt;/td&gt;
&lt;td&gt;100% valid&lt;/td&gt;
&lt;td&gt;118% / 80 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteLLM 1.106, 2 workers&lt;/td&gt;
&lt;td&gt;+3.82 / +3.72 ms&lt;/td&gt;
&lt;td&gt;+10.1 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;28% valid&lt;/strong&gt; , 368 rps served&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28% valid&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200% / 2 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Added latency is measured at 100 requests per second; CPU and memory are the peaks over all scenarios, and 200% means both CPUs of the budget were busy. What the numbers say:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The hop itself is cheap.&lt;/strong&gt; Below their limits, nginx, Kong and Portkey added less than 2 ms to a chat reply and LiteLLM about 4 ms. A hosted model usually needs hundreds of milliseconds before its first token, so a gateway in normal operation is a rounding error next to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacity is where they really differ.&lt;/strong&gt; With two workers on two CPUs, LiteLLM kept up to about &lt;strong&gt;400 chat requests per second&lt;/strong&gt; : 100% valid and a p99 of 51 ms in a separate step test. At 500 the median reply already took 0.7 s, at 1,000 only 60% of requests completed, and both CPUs sat at 100% the whole time. Streaming tops out lower, at about &lt;strong&gt;100 per second&lt;/strong&gt; : at 150 it served only 114 per second and the first token took two seconds. nginx, Kong and Portkey handled every scenario without a single invalid answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory differs by two orders of magnitude.&lt;/strong&gt; nginx needed 10 MiB, Portkey 80 MiB, Kong 300 MiB. LiteLLM with two workers used 1.5 GiB while idle and stayed just under the 2 GB limit under load, without being killed. Give it more memory than this test did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portkey adds a fixed 28 ms to the first streamed token.&lt;/strong&gt; It does not depend on load: 28 ms at 100 streams per second, 26 ms at 500. To check whether Portkey buffers whole streams, I slowed the mock down to 20 ms and then 100 ms between chunks. The full stream took exactly as long as a direct call, and the first chunk still arrived about 30 ms late. So the stream is passed through as it arrives, and the cost is a constant on the first chunk. For a chat window that is invisible; for a voice agent that chains several model calls it adds up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kong stays closest to the floor.&lt;/strong&gt; About half a millisecond above plain nginx, which is what you would expect from Lua running inside nginx, with moderate memory. The catch is the license: open-source images stop at 3.9, so new AI plugin features land in the commercial versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What this test does not show: a real model’s latency, a logging database, caching or guardrails, and anything above 2 CPUs. Adding a database for keys and spend logs costs every gateway something, and LiteLLM is usually run with one. All of it ran on a single machine under Docker, which makes p99 values noisy; compare gateways within one run, not with numbers from other benchmarks. More replicas raise capacity for every gateway here. The point of the test is what one replica on a fixed budget does.&lt;/p&gt;

&lt;p&gt;Tip&lt;/p&gt;

&lt;p&gt;Check it yourself in three commands: &lt;code&gt;git clone https://github.com/LF3551/ai-gateway-benchmark&lt;/code&gt;, then &lt;code&gt;docker compose build&lt;/code&gt; and &lt;code&gt;node scripts/bench.mjs &amp;amp;&amp;amp; node scripts/report.mjs&lt;/code&gt;. The &lt;a href="https://github.com/LF3551/ai-gateway-benchmark" rel="noopener noreferrer"&gt;repository&lt;/a&gt; has the mock model, the load generator, every gateway config and every image pinned by digest. No provider account or API key is needed. Single-machine numbers are a sanity check, not a leaderboard: compare gateways within one run, not with numbers from another machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosted or managed?
&lt;/h2&gt;

&lt;p&gt;Both kinds do the core job, whether you are choosing a first gateway or looking for a LiteLLM alternative. The difference is who sees your traffic, who runs the service, and how you pay.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Written in&lt;/th&gt;
&lt;th&gt;License / pricing&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LiteLLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;self-hosted (also a Python SDK)&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;open source core, paid enterprise features&lt;/td&gt;
&lt;td&gt;100+ providers, virtual keys, budgets, Postgres for keys and spend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kong AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;self-hosted or Konnect&lt;/td&gt;
&lt;td&gt;Lua on NGINX&lt;/td&gt;
&lt;td&gt;the last open-source image is 3.9; newer Kong Gateway images need a license&lt;/td&gt;
&lt;td&gt;a full API gateway with a large AI plugin set: proxy, semantic cache, guardrails, PII sanitizer, MCP proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Portkey Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;self-hosted or hosted&lt;/td&gt;
&lt;td&gt;TypeScript (Node.js)&lt;/td&gt;
&lt;td&gt;MIT; hosted plans from free (10,000 logged requests a month)&lt;/td&gt;
&lt;td&gt;now part of Palo Alto Networks as Prisma AIRS AI Gateway; open-source 2.0 in pre-release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;managed&lt;/td&gt;
&lt;td&gt;runs on Cloudflare’s network&lt;/td&gt;
&lt;td&gt;core features free&lt;/td&gt;
&lt;td&gt;caching, rate limiting, retries and fallback, logs; unified billing adds a fee on credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vercel AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;managed&lt;/td&gt;
&lt;td&gt;runs on Vercel&lt;/td&gt;
&lt;td&gt;no markup on provider token prices, also with your own keys&lt;/td&gt;
&lt;td&gt;budgets per team, project, key or member; fallbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenRouter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;managed&lt;/td&gt;
&lt;td&gt;hosted service&lt;/td&gt;
&lt;td&gt;pay per token through one account&lt;/td&gt;
&lt;td&gt;one key for hundreds of models from many providers; check the current fees on its pricing page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical notes from building the benchmark. &lt;strong&gt;Kong&lt;/strong&gt; : the &lt;code&gt;kong/kong-gateway&lt;/code&gt; images from 3.10 onwards start in read-only mode without a license and answered every request with &lt;code&gt;426&lt;/code&gt;, so the benchmark uses &lt;code&gt;kong:3.9&lt;/code&gt;, the last open-source image, where the &lt;code&gt;ai-proxy&lt;/code&gt; plugin works without one. &lt;strong&gt;LiteLLM&lt;/strong&gt; : the 1.106 proxy refuses to start without a strong master key (&lt;code&gt;UnsafeMasterKeyError&lt;/code&gt;), which is the right default and one more reason to read the next section.&lt;/p&gt;

&lt;p&gt;Note&lt;/p&gt;

&lt;p&gt;The European angle. A managed gateway sees every prompt and every answer, so under the GDPR its operator is a processor: you need a data processing agreement, and you need to know where the logs are stored and for how long. If that is hard to answer for your data, the gateway belongs in your own network, next to the &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/ai/vllm-vs-ollama-inference-server-2026" rel="noopener noreferrer"&gt;self-hosted model servers&lt;/a&gt; it fronts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bites in production
&lt;/h2&gt;

&lt;p&gt;On a diagram a gateway is a clean box. These are the parts that hurt once real traffic runs through it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buffering.&lt;/strong&gt; Any proxy on the path that buffers responses turns a stream into one late blob, and the user stares at an empty box. NGINX buffers by default (&lt;code&gt;proxy_buffering on&lt;/code&gt;); switch it off for streaming routes on every hop, not only in the gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle timeouts.&lt;/strong&gt; Reasoning models can think for a minute before the first token. The common 60-second defaults, NGINX’s &lt;code&gt;proxy_read_timeout&lt;/code&gt; or the idle timeout of an AWS Application Load Balancer, cut those requests off. The client sees a dropped connection, retries, and you pay twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token counts.&lt;/strong&gt; In OpenAI-style streaming, usage arrives only in the last chunk and only when requested (&lt;code&gt;stream_options: {"include_usage": true}&lt;/code&gt;). A gateway that does not ask for it has to estimate, and its cost report drifts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No fallback after the first token.&lt;/strong&gt; Once a chunk has reached the client, the gateway cannot switch providers without sending a second, different answer. Fallback covers failures before the first chunk; after that, a broken stream is a client-side retry.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fallback is not free
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Another model is another product.&lt;/strong&gt; Different tokenizer, context window, tool-calling quirks, JSON behaviour and refusal style. A prompt tuned for one model can come back in a different shape from the other, and your parsing breaks at the worst moment: during an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning state does not travel.&lt;/strong&gt; Encrypted reasoning from one provider cannot be read by another. LiteLLM’s documentation describes dropping it on fallback, so the backup model starts that turn’s thinking from scratch. Long agent runs that fail over get slower and may take a different path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries multiply.&lt;/strong&gt; The OpenAI Python SDK retries twice by default. Two retries in the SDK, two in the gateway and two in your own code turn one failed call into up to 27 attempts against a provider that is already rate-limiting you. Retry in one layer, the gateway, and set the client to zero, as in the snippet above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budgets and access rules must cover the fallback target.&lt;/strong&gt; A request for a cheap model that falls back to an expensive one is billed at the expensive price. LiteLLM re-checks budget and model access for every fallback target; whatever you run, test that a key without access to a model cannot reach it through a fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Budgets are estimates
&lt;/h3&gt;

&lt;p&gt;A gateway cannot know what a request costs until the answer exists. It either reserves the worst case from &lt;code&gt;max_tokens&lt;/code&gt; (safe, but blocks early) or charges afterwards (accurate, but overshoots: fifty parallel agent calls all pass the check before the first one is billed). With several gateway replicas the counters live in a shared store such as Redis and have the same race conditions as any distributed rate limiter. Treat a budget as a soft cap with a known overshoot, and alert well before it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching can leak, and can break the cheaper cache
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cache key must include everything that changes the answer:&lt;/strong&gt; model, system prompt, parameters, tools, and the &lt;strong&gt;tenant&lt;/strong&gt;. A shared semantic cache that ignores the tenant can hand one customer’s answer, with their data in it, to another customer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Providers cache too.&lt;/strong&gt; OpenAI and Anthropic bill repeated prompt prefixes at a discount, but only when the prefix is byte-for-byte identical. A gateway that injects a timestamp, a request ID or per-user guardrail text at the start of the prompt breaks that prefix and quietly raises your bill. Anything dynamic goes at the end.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The gateway is now on every critical path
&lt;/h3&gt;

&lt;p&gt;Once all model traffic goes through it, a gateway outage is an outage of every AI feature you have. Run at least two replicas, keep the request path stateless, and decide per function what happens when its database or Redis is down: logging can fail open, authentication and budgets should fail closed. Deploy it close to the apps, because every hop shows up in time to first token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agents multiply everything
&lt;/h3&gt;

&lt;p&gt;One click in an agent product can mean dozens of model calls and tool calls. Per-request limits stop meaning much; you want budgets and step caps per run, a loop detector, and the run ID on every log line so a single runaway task is visible as one thing. Many gateways now also sit in front of MCP tool calls, which is the same idea applied to tools: one place that decides which agent may call what.&lt;/p&gt;

&lt;p&gt;For logs, use a format your stack already understands. OpenTelemetry defines GenAI semantic conventions (&lt;code&gt;gen_ai.request.model&lt;/code&gt;, &lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt;, &lt;code&gt;gen_ai.usage.output_tokens&lt;/code&gt;), and gateways that export them plug into the tracing you already run. Do not log full prompts and answers by default; the next section shows why.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gateway holds all your keys: the LiteLLM 1.107.0 lesson
&lt;/h2&gt;

&lt;p&gt;Putting every provider key behind one door also means one door to break. On &lt;strong&gt;21 October 2026&lt;/strong&gt; , all seven wheels of &lt;strong&gt;LiteLLM 1.107.0&lt;/strong&gt; on PyPI shipped with two extra files, &lt;code&gt;cook.pth&lt;/code&gt; and &lt;code&gt;sitecustomize.py&lt;/code&gt;, which Python runs at startup in every process of the environment. According to the &lt;a href="https://safedep.io/litellm-pypi-compromise-credential-stealer-backdoor/" rel="noopener noreferrer"&gt;analysis by SafeDep&lt;/a&gt; and &lt;a href="https://litellm.ai/blog?post=security-incident-oct-21-2026" rel="noopener noreferrer"&gt;LiteLLM’s incident report&lt;/a&gt;, the first collected SSH keys, cloud and Kubernetes credentials, CI secrets, browser passwords, tokens of AI coding tools and environment variables, and the second started a daemon that fetched and ran new code every hour. For a gateway the worst detail is this: when &lt;code&gt;DATABASE_URL&lt;/code&gt; was set, the stealer also dumped the proxy’s credentials and model tables, which means &lt;strong&gt;every provider key the gateway stored&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The timeline, in UTC: the wheels went up at 11:39–11:40, compromised Docker images followed at 11:46 (tags &lt;code&gt;v1.107.0&lt;/code&gt;, &lt;code&gt;latest&lt;/code&gt;, &lt;code&gt;main-latest&lt;/code&gt;, &lt;code&gt;main-stable&lt;/code&gt;), PyPI removed the release by 13:23, and the last Docker tags were fixed at 14:20. Clean versions are 1.106.2 and the 1.107.0 release candidates. The root cause is the uncomfortable part for every gateway operator. The attacker held GitHub CLI tokens of three developers, and LiteLLM believes they came from the &lt;strong&gt;logs of an internal LiteLLM deployment that was reachable from the internet&lt;/strong&gt; : a coding agent had put the token into a response body, and the gateway logged response bodies. With those tokens the attacker pointed the release workflow at a lookalike repository, and the official pipeline built, signed and published the result, so a valid signature did not help.&lt;/p&gt;

&lt;p&gt;What to take from it, whichever gateway you run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin by digest, update on purpose.&lt;/strong&gt; &lt;code&gt;latest&lt;/code&gt; and &lt;code&gt;main-stable&lt;/code&gt; were among the poisoned tags; the benchmark repository pins every image by &lt;code&gt;sha256&lt;/code&gt;. Pinning is not “never update”: LiteLLM suspects the way into its internal deployment was a recently patched vulnerability. The &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/security/npm-supply-chain-attack-mitigation-2026" rel="noopener noreferrer"&gt;npm supply-chain checklist&lt;/a&gt; applies to PyPI almost word for word.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logged prompts and answers are secrets.&lt;/strong&gt; Whatever passes through the gateway ends up in its logs: tokens pasted by users, keys echoed by agents, personal data. Redact before storing, keep retention short and restrict who can read them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the gateway’s admin side as the crown jewels.&lt;/strong&gt; No admin UI or API on the internet, SSO in front of it, and an alert on every new user or key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep provider keys out of the gateway’s database if you can.&lt;/strong&gt; Read them from a secret manager at runtime and rotate them; a stolen database dump is then worth much less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the gateway its own narrow network path.&lt;/strong&gt; It needs to reach the providers, not your whole cloud account or the instance metadata service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you installed 1.107.0 anywhere,&lt;/strong&gt; follow the incident report and rotate every key that machine could read, not only the provider keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;An AI gateway, or LLM gateway, is not a new idea. It is the API gateway pattern applied to a kind of traffic that is billed by the token, streams for minutes and fails over between vendors. When the second team, the second provider or the first compliance question arrives, it pays for itself.&lt;/p&gt;

&lt;p&gt;The benchmark puts a number on the fear of the extra hop: a gateway with enough capacity adds a millisecond or a few, far less than any model takes to answer. What separates the options is how much traffic one replica carries, how it streams, where it runs and who holds the keys.&lt;/p&gt;

&lt;p&gt;To measure it on your own hardware, the &lt;a href="https://github.com/LF3551/ai-gateway-benchmark" rel="noopener noreferrer"&gt;benchmark repository&lt;/a&gt; runs everything with Docker in three commands.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/ai/ai-gateway-llm-gateway-explained-latency-benchmark" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aigateway</category>
      <category>llmgateway</category>
      <category>llmproxy</category>
      <category>litellm</category>
    </item>
    <item>
      <title>AI Gateway (LLM Gateway) erklärt: was es tut, wann Sie eins brauchen und was es kostet</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/ai-gateway-llm-gateway-erklart-was-es-tut-wann-sie-eins-brauchen-und-was-es-kostet-51ng</link>
      <guid>https://dev.to/aleksei_aleinikov/ai-gateway-llm-gateway-erklart-was-es-tut-wann-sie-eins-brauchen-und-was-es-kostet-51ng</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyiyul2rtiwb02t0i4ik.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyiyul2rtiwb02t0i4ik.webp" alt="AI Gateway (LLM Gateway) erklärt: was es tut, wann Sie eins brauchen und was es kostet" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Die erste LLM-Funktion ist einfach. Ein API-Schlüssel in eine Umgebungsvariable, das SDK des Anbieters aufrufen, ausliefern. Ärger macht die zweite und dritte: drei Anbieterschlüssel in fünf Diensten, niemand kann sagen, welches Team letzten Dienstag das Geld ausgegeben hat, und der Ausfall eines Anbieters reißt das ganze Produkt mit. Die übliche Antwort auf alle drei Probleme ist dasselbe Stück Infrastruktur, und es hat zwei Namen: &lt;strong&gt;AI Gateway&lt;/strong&gt; und &lt;strong&gt;LLM Gateway&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Dieser Leitfaden erklärt, was ein AI Gateway ist, was darin steckt, wie es sich von Ihrem vorhandenen API Gateway unterscheidet, wann es sich lohnt, was sich in Ihrem Code ändert und was bricht, sobald echter Verkehr hindurchläuft. Und er misst den Teil, den die Landingpages der Anbieter auslassen: was der zusätzliche Hop kostet. Vier Gateways, ein Mock-Modell, dasselbe CPU-Budget für jedes und ein Repository, das Sie selbst ausführen können.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyiyul2rtiwb02t0i4ik.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyiyul2rtiwb02t0i4ik.webp" alt="AI Gateway erklärt: Anfragen aus den Anwendungen laufen durch ein Stellwerk, das Schlüssel, Limits, Cache, Routing und Logs hält und auf einen anderen Modellanbieter umstellt, wenn einer 429 liefert." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Was ist ein AI Gateway?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ein AI Gateway, auch LLM Gateway genannt, ist ein Proxy zwischen Ihren Anwendungen und den Modellanbietern, die sie aufrufen.&lt;/strong&gt; Jede Anwendung schickt ihre Anfragen an einen Endpunkt, meist OpenAI-kompatibel, mit einem Schlüssel, den das Gateway ausgegeben hat. Das Gateway prüft, wer anfragt, setzt Request- und Token-Limits sowie Budgets durch, wählt Anbieter und Modell, wiederholt oder weicht aus, wenn ein Anbieter ausfällt, und protokolliert Tokens, Kosten und Latenz jedes Aufrufs. Die echten Anbieterschlüssel verlassen es nie.&lt;/p&gt;

&lt;p&gt;Das ist die ganze Idee: eine Tür zwischen Ihrem Code und jedem Modell. Der Rest dieses Artikels handelt davon, was an dieser Tür passiert.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Gateway vs. LLM Gateway vs. LLM Proxy vs. LLM Router
&lt;/h2&gt;

&lt;p&gt;Das Vokabular ist unübersichtlicher als das Konzept. Diese Begriffe begegnen Ihnen in Dokus und auf Anbieterseiten:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Begriff&lt;/th&gt;
&lt;th&gt;Was er in der Praxis bedeutet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Das Gesamtpaket: Auth, Limits, Budgets, Routing, Fallback, Caching, Guardrails, Logs. Deckt Chat und meist auch Embeddings, Bilder, Audio und MCP-Tool-Aufrufe ab.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dasselbe Produkt unter anderem Namen, mit Schwerpunkt auf Textmodellen. Wird austauschbar mit “AI Gateway” verwendet.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM Proxy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Die minimale Variante: ein OpenAI-kompatibler Endpunkt, der Anfragen an viele Anbieter weiterleitet und übersetzt. Oft der Ausgangspunkt eines Gateways.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM Router&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Der Teil, der entscheidet, welches Modell antwortet: nach Alias, Preis, Latenz, Last oder Inhalt des Prompts. Mal Funktion eines Gateways, mal eigener Dienst.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Der allgemeine Proxy für HTTP-APIs (Kong, Envoy, NGINX, API Gateways der Clouds). Manche bekommen KI-Plugins und werden so ebenfalls zu AI Gateways.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Was in einem AI Gateway steckt
&lt;/h2&gt;

&lt;p&gt;Jedes Gateway am Markt kombiniert dieselben Bausteine. Manche bieten alle, manche nur einige:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco5n4rd0u9hkutabo3am.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco5n4rd0u9hkutabo3am.webp" alt="Aufbau eines AI Gateways: Anfragen durchlaufen Auth, Limits, Cache und Guardrails, ein Router wählt den Anbieter und weicht bei 429 aus, Antworten durchlaufen Ausgabeprüfung, Kostenerfassung, Cache und Logs. Eine Control Plane hält Teams, Schlüssel, Budgets und Anbietergeheimnisse." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Anfrageweg von links nach rechts, Antwortweg zurück. Die Anbieterschlüssel liegen in der Control Plane und erreichen nie eine Anwendung.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth und virtuelle Schlüssel.&lt;/strong&gt; Anwendungen bekommen Schlüssel vom Gateway, je Team, Anwendung oder Nutzer, und das Gateway tauscht sie auf dem Weg nach draußen gegen den echten Anbieterschlüssel. Ein geleakter Schlüssel bedeutet nicht mehr, den OpenAI-Schlüssel an zwölf Stellen zu rotieren.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits.&lt;/strong&gt; Nicht nur Anfragen pro Sekunde, sondern &lt;strong&gt;Tokens pro Minute&lt;/strong&gt; und &lt;strong&gt;Tokens pro Tag&lt;/strong&gt; , denn eine Anfrage kann 200 oder 200.000 Tokens kosten. Die Algorithmen sind dieselben wie bei jeder API, die Fehler auch; dazu habe ich &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/rate-limiting-in-der-praxis-algorithmen-header-2026" rel="noopener noreferrer"&gt;Rate Limiting in der Praxis&lt;/a&gt; geschrieben.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budgets.&lt;/strong&gt; Ausgabengrenzen je Team, Schlüssel oder Projekt, in Geld gezählt. Das Gateway kennt den Preis jedes Modells und kann einen außer Kontrolle geratenen Agenten bei einem festen Betrag stoppen statt am Monatsende.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing, Retries und Fallback.&lt;/strong&gt; Modell-Aliase (&lt;code&gt;chat-default&lt;/code&gt; zeigt auf das, was Sie diese Woche festlegen), Lastverteilung über Schlüssel oder Regionen, Wiederholungen mit Backoff und Ausweichen auf einen anderen Anbieter, wenn der erste &lt;code&gt;429&lt;/code&gt; oder &lt;code&gt;5xx&lt;/code&gt; liefert. Weil Anbieter unterschiedliche APIs haben, übersetzt das Gateway Anfrage- und Antwortformate im Flug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching.&lt;/strong&gt; Exaktes Caching identischer Prompts ist billig und sicher. Semantisches Caching beantwortet auch nur ähnliche Prompts, spart mehr und liegt öfter daneben.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails.&lt;/strong&gt; Prüfungen von Prompts und Antworten: personenbezogene Daten, Geheimnisse, gesperrte Themen, Muster von Prompt Injection. Sie helfen, sind allein aber keine Sicherheitsgrenze; warum, steht in &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/security/prompt-injection-abwehr-2026-lethal-trifecta-test" rel="noopener noreferrer"&gt;Prompt-Injection-Abwehr&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability und Kosten.&lt;/strong&gt; Jeder Aufruf protokolliert mit Modell, Tokens, Latenz, Kosten, Team und Schlüssel. Oft ist das der erste Grund für ein Gateway: Die Finanzabteilung will den Bericht.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Selbst betriebene Modelle behandelt das Gateway genauso. Ein &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/ai/vllm-vs-ollama-inference-server-2026-vergleich" rel="noopener noreferrer"&gt;vLLM- oder Ollama-Server&lt;/a&gt; wird ein weiterer Anbieter hinter demselben Endpunkt, mit denselben Schlüsseln, Limits und Logs.&lt;/p&gt;
&lt;h2&gt;
  
  
  AI Gateway vs. API Gateway
&lt;/h2&gt;

&lt;p&gt;Wenn Sie bereits ein API Gateway betreiben, liegt die Frage nahe, warum das nicht reicht. Teilweise reicht es. Kong ist buchstäblich ein API Gateway mit KI-Plugins. Einem reinen API Gateway fehlt aber, was LLM-Verkehr anders macht:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;API Gateway&lt;/th&gt;
&lt;th&gt;AI Gateway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Einheit der Limits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Anfragen pro Sekunde&lt;/td&gt;
&lt;td&gt;Anfragen &lt;strong&gt;und&lt;/strong&gt; Tokens pro Minute, je Schlüssel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kosten&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;nicht sein Thema&lt;/td&gt;
&lt;td&gt;Preis je Modell, Budgets, Ausgaben je Team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Typische Antwort&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;kleines JSON in Millisekunden&lt;/td&gt;
&lt;td&gt;langer Text, oft über Sekunden oder Minuten gestreamt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Failover&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;andere Replik desselben Dienstes&lt;/td&gt;
&lt;td&gt;anderer Anbieter mit anderer API und anderem Modell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache-Schlüssel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;URL und Header&lt;/td&gt;
&lt;td&gt;der Prompt, optional nach Bedeutung&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inhaltsprüfung&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Schema, Größe, Injection-Muster&lt;/td&gt;
&lt;td&gt;personenbezogene Daten, Geheimnisse, Prompt Injection, Richtlinien&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Logs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Status, Latenz, Bytes&lt;/td&gt;
&lt;td&gt;Modell, Prompt- und Completion-Tokens, Kosten, Nutzer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kurz: Ein API Gateway zählt Anfragen, ein AI Gateway zählt Tokens und Geld.&lt;/p&gt;
&lt;h2&gt;
  
  
  Brauchen Sie ein AI Gateway?
&lt;/h2&gt;

&lt;p&gt;Nicht immer. Ein Gateway ist ein weiterer Dienst, den Sie betreiben, absichern und aktuell halten müssen, und es liegt im heißen Pfad jedes Modellaufrufs. Für eine einzelne Anwendung mit einem Anbieter fügt es vor allem einen Hop hinzu. Drei Fragen entscheiden:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F58yin38ghi9x2qt0i154.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F58yin38ghi9x2qt0i154.webp" alt="Entscheidungsbaum: Eine Anwendung mit einem Anbieter kommt ohne Gateway aus; ohne geteilte Schlüssel oder Budgets reicht eine Client-Bibliothek; müssen Prompts und Logs im eigenen Netz bleiben, betreiben Sie ein Gateway selbst; sonst nutzen Sie ein gemanagtes." width="800" height="467"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Die meisten Teams kommen schneller an Frage 2 als gedacht: Das zweite Team, das denselben OpenAI-Schlüssel will, ist der Moment.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Die mittlere Option verdient einen Satz. Eine &lt;strong&gt;Client-Bibliothek&lt;/strong&gt; , die viele Anbieter kapselt (das LiteLLM-Python-SDK, das Vercel AI SDK und andere), liefert eine API, Retries und Fallback in Ihrem Prozess, ohne Netzwerk-Hop. Was sie nicht liefern kann, ist ein zentraler Ort für Schlüssel, Budgets und Logs über Teams hinweg. An dieser Linie hört die Bibliothek auf und das Gateway fängt an.&lt;/p&gt;

&lt;p&gt;Und selbst schreiben? Ein Proxy, der Anfragen im OpenAI-Format weiterleitet, ist ein Nachmittag Arbeit. Die Monate stecken in dem, was danach kommt: Übersetzen zwischen Anbieter-APIs, die sich ständig ändern (neue Endpunkte, Reasoning-Parameter, Tool-Formate), korrektes Token-Zählen bei Streams, aktuelle Preistabellen und jeder Sonderfall von Server-Sent Events. Solange das Gateway nicht Ihr Produkt ist, starten Sie mit einem vorhandenen.&lt;/p&gt;
&lt;h2&gt;
  
  
  Einbau: was sich in Ihrem Code ändert
&lt;/h2&gt;

&lt;p&gt;Weniger als gedacht. Die meisten Gateways sprechen die OpenAI-API, die Änderung in der Anwendung sind also zwei Zeilen: Basis-URL und Schlüssel.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;app.py: die einzige Änderung auf Client-Seite&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://llm-gateway.internal/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# das Gateway, nicht der Anbieter
&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LLM_GATEWAY_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;# ein virtueller Schlüssel für diese App
&lt;/span&gt;
    &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Retries macht das Gateway, siehe unten
&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&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="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat-default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# ein Alias, den das Gateway auflöst
&lt;/span&gt;
    &lt;span class="n"&gt;messages&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;role&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;user&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;content&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;Fasse dieses Support-Ticket zusammen.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Der Modellname ist jetzt ein Alias. Welcher Anbieter und welches Modell hinter &lt;code&gt;chat-default&lt;/code&gt; stehen und was bei einem Fehler passiert, liegt im Gateway. Im LiteLLM-Proxy sieht das so aus:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;config.yaml (LiteLLM-Proxy)&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="na"&gt;model_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chat-default&lt;/span&gt;

    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropic/claude-sonnet-5-5&lt;/span&gt;

      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/ANTHROPIC_API_KEY&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chat-backup&lt;/span&gt;

    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openai/gpt-6.1-sol&lt;/span&gt;

      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/OPENAI_API_KEY&lt;/span&gt;

&lt;span class="na"&gt;litellm_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

  &lt;span class="na"&gt;num_retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;

  &lt;span class="na"&gt;fallbacks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat-default"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat-backup"&lt;/span&gt;&lt;span class="pi"&gt;]}]&lt;/span&gt;

&lt;span class="na"&gt;general_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

  &lt;span class="na"&gt;master_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/LITELLM_MASTER_KEY&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Kong, Portkey und die gemanagten Gateways schreiben es anders, die Form ist aber dieselbe: Aliase nach außen, Anbieter und Schlüssel innen. Ein anderes Standardmodell wird zur Konfigurationsänderung statt zum Redeploy aller Dienste.&lt;/p&gt;

&lt;p&gt;Eine Einführungsreihenfolge ohne böse Überraschungen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Umleiten und beobachten, nichts ändern.&lt;/strong&gt; Einen Dienst mit großzügigen Limits und nur aktiviertem Logging auf das Gateway zeigen lassen. Sie bekommen den ersten ehrlichen Kostenbericht und eine Latenz-Baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Virtuelle Schlüssel ausgeben, dann die direkten widerrufen.&lt;/strong&gt; Ein Schlüssel je App oder Team. Ein Anbieterschlüssel, der noch in irgendeiner &lt;code&gt;.env&lt;/code&gt;-Datei liegt, macht das Gateway optional, und optionale Kontrollen werden umgangen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budgets und Limits zuerst nur berichten lassen.&lt;/strong&gt; Prüfen Sie, wer letzte Woche blockiert worden wäre, bevor Sie jemanden blockieren.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback zuletzt, und getestet.&lt;/strong&gt; Lassen Sie Ihre Evals gegen das Fallback-Modell laufen, bevor es je einen Nutzer bedient. Warum, steht im Abschnitt zum Betrieb weiter unten.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Der Benchmark: was kostet der zusätzliche Hop?
&lt;/h2&gt;

&lt;p&gt;Ein AI Gateway ist ein zusätzlicher Netzwerk-Hop und ein zusätzlicher Prozess bei jedem Modellaufruf, die faire Frage ist also, was das kostet. Anbieterseiten antworten mit einer einzelnen Zahl, gemessen auf eigener Hardware. Ich wollte alle unter denselben Bedingungen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Der Aufbau.&lt;/strong&gt; Ein kleiner Go-Server spielt das Modell: Er spricht die OpenAI-Chat-API und antwortet sofort, mit einer 1,4-KB-Antwort oder einem Stream aus 40 Chunks. Weil das Modell keine Zeit braucht, ist jede Millisekunde über einem direkten Aufruf Arbeit des Gateways. Jedes Gateway läuft allein in Docker mit demselben Budget von &lt;strong&gt;2 CPUs und 2 GB&lt;/strong&gt; , Standardeinstellungen, ohne Caching, ohne Guardrails und ohne Logging-Datenbank. Ein einfacher &lt;strong&gt;nginx&lt;/strong&gt; -Reverse-Proxy setzt die Untergrenze: die Kosten eines zusätzlichen Hops, der nichts tut. Die Last ist open-loop, Anfragen kommen also weiter nach Plan, auch wenn ein Gateway langsamer wird, und eine Anfrage zählt nur, wenn die Antwort vollständig und gültig ist. Chat läuft mit 100, 500, 1.000 und 2.000 Anfragen pro Sekunde, Streaming mit 100 und 500; jedes Szenario läuft 3 Runden zu 20 Sekunden nach einer Aufwärmphase, die Tabellen zeigen die mittlere Runde.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gj2hnzo6irl0qhi7qgn.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gj2hnzo6irl0qhi7qgn.webp" alt="Benchmark-Ergebnisse: zusätzliche Zeit für eine Chat-Antwort und für das erste gestreamte Token bei 100 Anfragen pro Sekunde sowie die höchste Rate, die jedes Gateway durchhielt. nginx, Kong und Portkey schafften 2.000 Chat-Anfragen pro Sekunde, LiteLLM etwa 400." width="800" height="471"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Balken: mittlere Zusatzzeit gegenüber einem direkten Aufruf bei 100 rps. Rechts: die höchste getestete Rate mit nur gültigen Antworten und p99 unter 100 ms.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gateway (Version)&lt;/th&gt;
&lt;th&gt;Zusatz zur Chat-Antwort, p50 / p99&lt;/th&gt;
&lt;th&gt;Zusatz zum ersten gestreamten Token, p50&lt;/th&gt;
&lt;th&gt;Chat, 2.000 rps&lt;/th&gt;
&lt;th&gt;Stream, 500 rps&lt;/th&gt;
&lt;th&gt;Spitze CPU / Speicher&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nginx 1.29 (Untergrenze)&lt;/td&gt;
&lt;td&gt;+0,31 / +1,84 ms&lt;/td&gt;
&lt;td&gt;+0,48 ms&lt;/td&gt;
&lt;td&gt;100 % gültig&lt;/td&gt;
&lt;td&gt;100 % gültig&lt;/td&gt;
&lt;td&gt;18 % / 10 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kong 3.9, &lt;code&gt;ai-proxy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;+0,85 / +2,55 ms&lt;/td&gt;
&lt;td&gt;+1,07 ms&lt;/td&gt;
&lt;td&gt;100 % gültig&lt;/td&gt;
&lt;td&gt;100 % gültig&lt;/td&gt;
&lt;td&gt;32 % / 300 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portkey 1.15&lt;/td&gt;
&lt;td&gt;+1,73 / +1,40 ms&lt;/td&gt;
&lt;td&gt;+27,9 ms&lt;/td&gt;
&lt;td&gt;100 % gültig&lt;/td&gt;
&lt;td&gt;100 % gültig&lt;/td&gt;
&lt;td&gt;118 % / 80 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteLLM 1.106, 2 Worker&lt;/td&gt;
&lt;td&gt;+3,82 / +3,72 ms&lt;/td&gt;
&lt;td&gt;+10,1 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;28 % gültig&lt;/strong&gt; , 368 rps bedient&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28 % gültig&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200 % / 2 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Die Zusatzlatenz ist bei 100 Anfragen pro Sekunde gemessen; CPU und Speicher sind die Spitzen über alle Szenarien, 200 % heißt, beide CPUs des Budgets waren ausgelastet. Was die Zahlen sagen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Der Hop selbst ist billig.&lt;/strong&gt; Unterhalb ihrer Grenzen fügten nginx, Kong und Portkey einer Chat-Antwort weniger als 2 ms hinzu, LiteLLM etwa 4 ms. Ein gehostetes Modell braucht meist Hunderte Millisekunden bis zum ersten Token, ein Gateway im Normalbetrieb ist daneben ein Rundungsfehler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bei der Kapazität unterscheiden sie sich wirklich.&lt;/strong&gt; Mit zwei Workern auf zwei CPUs hielt LiteLLM etwa &lt;strong&gt;400 Chat-Anfragen pro Sekunde&lt;/strong&gt; durch: 100 % gültig und p99 von 51 ms in einem separaten Stufentest. Bei 500 dauerte die mittlere Antwort schon 0,7 s, bei 1.000 kamen nur 60 % der Anfragen durch, und beide CPUs standen die ganze Zeit auf 100 %. Streaming endet früher, bei etwa &lt;strong&gt;100 pro Sekunde&lt;/strong&gt; : Bei 150 bediente es nur 114 pro Sekunde, und das erste Token kam nach zwei Sekunden. nginx, Kong und Portkey bewältigten jedes Szenario ohne eine einzige ungültige Antwort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Der Speicher unterscheidet sich um zwei Größenordnungen.&lt;/strong&gt; nginx brauchte 10 MiB, Portkey 80 MiB, Kong 300 MiB. LiteLLM mit zwei Workern belegte im Leerlauf 1,5 GiB und blieb unter Last knapp unter der 2-GB-Grenze, ohne beendet zu werden. Geben Sie ihm mehr Speicher als dieser Test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portkey fügt dem ersten gestreamten Token feste 28 ms hinzu.&lt;/strong&gt; Das hängt nicht von der Last ab: 28 ms bei 100 Streams pro Sekunde, 26 ms bei 500. Um zu prüfen, ob Portkey ganze Streams puffert, habe ich den Mock auf 20 ms und dann 100 ms zwischen den Chunks verlangsamt. Der gesamte Stream dauerte genau so lange wie ein direkter Aufruf, und der erste Chunk kam trotzdem rund 30 ms später. Der Stream wird also so durchgereicht, wie er ankommt, und die Kosten sind eine Konstante beim ersten Chunk. In einem Chatfenster ist das unsichtbar; bei einem Sprachagenten, der mehrere Modellaufrufe verkettet, summiert es sich.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kong bleibt am nächsten an der Untergrenze.&lt;/strong&gt; Etwa eine halbe Millisekunde über reinem nginx, wie man es von Lua innerhalb von nginx erwartet, bei moderatem Speicher. Der Haken ist die Lizenz: Open-Source-Images enden bei 3.9, neue KI-Plugin-Funktionen landen in den kommerziellen Versionen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Was dieser Test nicht zeigt: die Latenz eines echten Modells, eine Logging-Datenbank, Caching oder Guardrails und alles über 2 CPUs. Eine Datenbank für Schlüssel und Ausgaben-Logs kostet jedes Gateway etwas, und LiteLLM läuft meist mit einer. Alles lief auf einer einzigen Maschine unter Docker, deshalb sind die p99-Werte verrauscht; vergleichen Sie Gateways innerhalb eines Laufs, nicht mit Zahlen aus anderen Benchmarks. Mehr Replikas erhöhen die Kapazität bei jedem Gateway hier. Der Test zeigt, was eine Replika mit festem Budget leistet.&lt;/p&gt;

&lt;p&gt;Tipp&lt;/p&gt;

&lt;p&gt;Prüfen Sie es selbst in drei Befehlen: &lt;code&gt;git clone https://github.com/LF3551/ai-gateway-benchmark&lt;/code&gt;, dann &lt;code&gt;docker compose build&lt;/code&gt; und &lt;code&gt;node scripts/bench.mjs &amp;amp;&amp;amp; node scripts/report.mjs&lt;/code&gt;. Das &lt;a href="https://github.com/LF3551/ai-gateway-benchmark" rel="noopener noreferrer"&gt;Repository&lt;/a&gt; enthält das Mock-Modell, den Lastgenerator, jede Gateway-Konfiguration und jedes Image per Digest gepinnt. Sie brauchen weder ein Konto bei einem Anbieter noch einen API-Schlüssel. Messungen auf einer Maschine sind eine Plausibilitätsprüfung, keine Rangliste: Vergleichen Sie Gateways innerhalb eines Laufs, nicht mit Zahlen von einer anderen Maschine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selbst betreiben oder gemanagt?
&lt;/h2&gt;

&lt;p&gt;Beide Arten erledigen die Kernaufgabe, ob Sie Ihr erstes Gateway wählen oder eine Alternative zu LiteLLM suchen. Der Unterschied: wer Ihren Verkehr sieht, wer den Dienst betreibt und wie Sie bezahlen.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Art&lt;/th&gt;
&lt;th&gt;Geschrieben in&lt;/th&gt;
&lt;th&gt;Lizenz / Preis&lt;/th&gt;
&lt;th&gt;Hinweise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LiteLLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;selbst betrieben (auch Python-SDK)&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Open-Source-Kern, kostenpflichtige Enterprise-Funktionen&lt;/td&gt;
&lt;td&gt;über 100 Anbieter, virtuelle Schlüssel, Budgets, Postgres für Schlüssel und Ausgaben&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kong AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;selbst betrieben oder Konnect&lt;/td&gt;
&lt;td&gt;Lua auf NGINX&lt;/td&gt;
&lt;td&gt;letztes Open-Source-Image ist 3.9; neuere Kong-Gateway-Images brauchen eine Lizenz&lt;/td&gt;
&lt;td&gt;vollwertiges API Gateway mit großem KI-Plugin-Set: Proxy, semantischer Cache, Guardrails, PII Sanitizer, MCP Proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Portkey Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;selbst betrieben oder gehostet&lt;/td&gt;
&lt;td&gt;TypeScript (Node.js)&lt;/td&gt;
&lt;td&gt;MIT; gehostete Pläne ab kostenlos (10.000 protokollierte Anfragen pro Monat)&lt;/td&gt;
&lt;td&gt;gehört jetzt zu Palo Alto Networks als Prisma AIRS AI Gateway; Open-Source-2.0 als Pre-Release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gemanagt&lt;/td&gt;
&lt;td&gt;läuft im Netz von Cloudflare&lt;/td&gt;
&lt;td&gt;Kernfunktionen kostenlos&lt;/td&gt;
&lt;td&gt;Caching, Rate Limiting, Retries und Fallback, Logs; Unified Billing erhebt eine Gebühr auf Guthaben&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vercel AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gemanagt&lt;/td&gt;
&lt;td&gt;läuft bei Vercel&lt;/td&gt;
&lt;td&gt;kein Aufschlag auf die Token-Preise der Anbieter, auch mit eigenen Schlüsseln&lt;/td&gt;
&lt;td&gt;Budgets je Team, Projekt, Schlüssel oder Mitglied; Fallbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenRouter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gemanagt&lt;/td&gt;
&lt;td&gt;gehosteter Dienst&lt;/td&gt;
&lt;td&gt;Bezahlung pro Token über ein Konto&lt;/td&gt;
&lt;td&gt;ein Schlüssel für Hunderte Modelle vieler Anbieter; aktuelle Gebühren auf der Preisseite prüfen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zwei praktische Hinweise aus dem Bau des Benchmarks. &lt;strong&gt;Kong&lt;/strong&gt; : Die Images &lt;code&gt;kong/kong-gateway&lt;/code&gt; ab 3.10 starten ohne Lizenz im Read-only-Modus und beantworteten jede Anfrage mit &lt;code&gt;426&lt;/code&gt;, deshalb nutzt der Benchmark &lt;code&gt;kong:3.9&lt;/code&gt;, das letzte Open-Source-Image, in dem das Plugin &lt;code&gt;ai-proxy&lt;/code&gt; ohne Lizenz funktioniert. &lt;strong&gt;LiteLLM&lt;/strong&gt; : Der Proxy in 1.106 startet ohne starken Master Key nicht (&lt;code&gt;UnsafeMasterKeyError&lt;/code&gt;). Das ist der richtige Standard und ein Grund mehr, den nächsten Abschnitt zu lesen.&lt;/p&gt;

&lt;p&gt;Hinweis&lt;/p&gt;

&lt;p&gt;Der europäische Blick. Ein gemanagtes Gateway sieht jeden Prompt und jede Antwort, sein Betreiber ist nach DSGVO also Auftragsverarbeiter: Sie brauchen einen Auftragsverarbeitungsvertrag und müssen wissen, wo die Logs liegen und wie lange. Lässt sich das für Ihre Daten schwer beantworten, gehört das Gateway ins eigene Netz, neben die &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/ai/vllm-vs-ollama-inference-server-2026-vergleich" rel="noopener noreferrer"&gt;selbst betriebenen Modellserver&lt;/a&gt;, vor denen es steht.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was im Betrieb wehtut
&lt;/h2&gt;

&lt;p&gt;Im Diagramm ist ein Gateway ein sauberer Kasten. Diese Stellen schmerzen, sobald echter Verkehr hindurchläuft.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pufferung.&lt;/strong&gt; Jeder Proxy auf dem Weg, der Antworten puffert, macht aus einem Stream einen späten Block, und der Nutzer starrt auf ein leeres Feld. NGINX puffert standardmäßig (&lt;code&gt;proxy_buffering on&lt;/code&gt;); schalten Sie das für Streaming-Routen auf jedem Hop ab, nicht nur im Gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle-Timeouts.&lt;/strong&gt; Reasoning-Modelle denken mitunter eine Minute vor dem ersten Token. Die üblichen 60-Sekunden-Standards, &lt;code&gt;proxy_read_timeout&lt;/code&gt; von NGINX oder das Idle-Timeout eines AWS Application Load Balancers, schneiden diese Anfragen ab. Der Client sieht eine abgebrochene Verbindung, wiederholt, und Sie zahlen doppelt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token-Zählung.&lt;/strong&gt; Beim Streaming im OpenAI-Stil kommt die Nutzung erst im letzten Chunk und nur auf Anfrage (&lt;code&gt;stream_options: {"include_usage": true}&lt;/code&gt;). Ein Gateway, das sie nicht anfordert, muss schätzen, und sein Kostenbericht driftet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kein Fallback nach dem ersten Token.&lt;/strong&gt; Ist ein Chunk beim Client angekommen, kann das Gateway nicht mehr den Anbieter wechseln, ohne eine zweite, andere Antwort zu senden. Fallback deckt Fehler vor dem ersten Chunk ab; danach ist ein abgebrochener Stream ein Retry auf Client-Seite.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fallback ist nicht gratis
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ein anderes Modell ist ein anderes Produkt.&lt;/strong&gt; Anderer Tokenizer, anderes Kontextfenster, andere Eigenheiten beim Tool Calling, bei JSON und bei Ablehnungen. Ein auf ein Modell abgestimmter Prompt kommt vom anderen in anderer Form zurück, und Ihr Parsing bricht im schlechtesten Moment: während eines Ausfalls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning-Zustand wandert nicht mit.&lt;/strong&gt; Verschlüsseltes Reasoning eines Anbieters kann ein anderer nicht lesen. Die LiteLLM-Dokumentation beschreibt, dass es beim Fallback verworfen wird, das Backup-Modell denkt diesen Schritt also neu. Lange Agentenläufe mit Failover werden langsamer und nehmen eventuell einen anderen Weg.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries multiplizieren sich.&lt;/strong&gt; Das OpenAI-Python-SDK wiederholt standardmäßig zweimal. Zwei Retries im SDK, zwei im Gateway und zwei im eigenen Code machen aus einem fehlgeschlagenen Aufruf bis zu 27 Versuche gegen einen Anbieter, der Sie ohnehin schon drosselt. Wiederholen Sie in einer Schicht, dem Gateway, und setzen Sie den Client auf null, wie im Beispiel oben.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budgets und Zugriffsregeln müssen das Fallback-Ziel abdecken.&lt;/strong&gt; Eine Anfrage an ein billiges Modell, die auf ein teures ausweicht, wird zum teuren Preis abgerechnet. LiteLLM prüft Budget und Modellzugriff für jedes Fallback-Ziel erneut; egal, was Sie betreiben: Testen Sie, dass ein Schlüssel ohne Zugriff auf ein Modell es nicht über einen Fallback erreicht.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Budgets sind Schätzungen
&lt;/h3&gt;

&lt;p&gt;Ein Gateway weiß erst, was eine Anfrage kostet, wenn die Antwort existiert. Entweder reserviert es den schlimmsten Fall aus &lt;code&gt;max_tokens&lt;/code&gt; (sicher, blockiert aber früh) oder rechnet hinterher ab (genau, schießt aber über: Fünfzig parallele Agentenaufrufe bestehen alle die Prüfung, bevor der erste abgerechnet ist). Mit mehreren Gateway-Replikas liegen die Zähler in einem gemeinsamen Speicher wie Redis und haben dieselben Race Conditions wie jeder verteilte Rate Limiter. Behandeln Sie ein Budget als weiche Grenze mit bekanntem Überschuss und alarmieren Sie deutlich vorher.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching kann leaken und den billigeren Cache zerstören
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Der Cache-Schlüssel muss alles enthalten, was die Antwort ändert:&lt;/strong&gt; Modell, System-Prompt, Parameter, Tools und den &lt;strong&gt;Mandanten&lt;/strong&gt;. Ein geteilter semantischer Cache, der den Mandanten ignoriert, kann die Antwort eines Kunden samt dessen Daten an einen anderen Kunden ausliefern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anbieter cachen auch.&lt;/strong&gt; OpenAI und Anthropic rechnen wiederholte Prompt-Präfixe mit Rabatt ab, aber nur, wenn das Präfix Byte für Byte identisch ist. Ein Gateway, das einen Zeitstempel, eine Request-ID oder nutzerspezifischen Guardrail-Text an den Anfang des Prompts setzt, zerstört dieses Präfix und erhöht still Ihre Rechnung. Alles Dynamische gehört ans Ende.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Das Gateway liegt jetzt auf jedem kritischen Pfad
&lt;/h3&gt;

&lt;p&gt;Läuft aller Modellverkehr darüber, ist ein Ausfall des Gateways ein Ausfall jeder KI-Funktion, die Sie haben. Betreiben Sie mindestens zwei Replikas, halten Sie den Anfragepfad zustandslos und entscheiden Sie je Funktion, was passiert, wenn Datenbank oder Redis weg sind: Logging darf offen ausfallen, Authentifizierung und Budgets sollten geschlossen ausfallen. Platzieren Sie es nah an den Anwendungen, denn jeder Hop zeigt sich in der Zeit bis zum ersten Token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agenten multiplizieren alles
&lt;/h3&gt;

&lt;p&gt;Ein Klick in einem Agentenprodukt kann Dutzende Modell- und Tool-Aufrufe bedeuten. Limits pro Anfrage sagen dann wenig; Sie brauchen Budgets und Schrittgrenzen pro Lauf, eine Schleifenerkennung und die Lauf-ID in jeder Logzeile, damit eine einzelne außer Kontrolle geratene Aufgabe als ein Ding sichtbar wird. Viele Gateways stehen inzwischen auch vor MCP-Tool-Aufrufen, dieselbe Idee für Tools: ein Ort, der entscheidet, welcher Agent was aufrufen darf.&lt;/p&gt;

&lt;p&gt;Für Logs nehmen Sie ein Format, das Ihr Stack schon versteht. OpenTelemetry definiert GenAI Semantic Conventions (&lt;code&gt;gen_ai.request.model&lt;/code&gt;, &lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt;, &lt;code&gt;gen_ai.usage.output_tokens&lt;/code&gt;), und Gateways, die sie exportieren, docken an das vorhandene Tracing an. Protokollieren Sie vollständige Prompts und Antworten nicht standardmäßig; warum, zeigt der nächste Abschnitt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ein Gateway hält alle Ihre Schlüssel: die Lehre aus LiteLLM 1.107.0
&lt;/h2&gt;

&lt;p&gt;Wer jeden Anbieterschlüssel hinter eine Tür legt, hat auch nur eine Tür, die aufgebrochen werden muss. Am &lt;strong&gt;21. Oktober 2026&lt;/strong&gt; enthielten alle sieben Wheels von &lt;strong&gt;LiteLLM 1.107.0&lt;/strong&gt; auf PyPI zwei zusätzliche Dateien, &lt;code&gt;cook.pth&lt;/code&gt; und &lt;code&gt;sitecustomize.py&lt;/code&gt;, die Python beim Start jedes Prozesses der Umgebung ausführt. Laut der &lt;a href="https://safedep.io/litellm-pypi-compromise-credential-stealer-backdoor/" rel="noopener noreferrer"&gt;Analyse von SafeDep&lt;/a&gt; und dem &lt;a href="https://litellm.ai/blog?post=security-incident-oct-21-2026" rel="noopener noreferrer"&gt;Incident Report von LiteLLM&lt;/a&gt; sammelte die erste SSH-Schlüssel, Cloud- und Kubernetes-Zugangsdaten, CI-Geheimnisse, Browser-Passwörter, Tokens von KI-Coding-Tools und Umgebungsvariablen, die zweite startete einen Daemon, der stündlich neuen Code holte und ausführte. Für ein Gateway ist dieses Detail das schlimmste: War &lt;code&gt;DATABASE_URL&lt;/code&gt; gesetzt, las der Stealer auch die Credential- und Modelltabellen des Proxys aus, also &lt;strong&gt;jeden Anbieterschlüssel, den das Gateway gespeichert hatte&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Der Ablauf in UTC: Die Wheels gingen um 11:39–11:40 online, kompromittierte Docker-Images folgten um 11:46 (Tags &lt;code&gt;v1.107.0&lt;/code&gt;, &lt;code&gt;latest&lt;/code&gt;, &lt;code&gt;main-latest&lt;/code&gt;, &lt;code&gt;main-stable&lt;/code&gt;), PyPI entfernte das Release bis 13:23, die letzten Docker-Tags waren um 14:20 korrigiert. Sauber sind 1.106.2 und die Release Candidates von 1.107.0. Die Ursache ist der unbequeme Teil für jeden Gateway-Betreiber. Der Angreifer besaß GitHub-CLI-Tokens von drei Entwicklern, und LiteLLM geht davon aus, dass sie aus den &lt;strong&gt;Logs einer internen LiteLLM-Instanz stammten, die aus dem Internet erreichbar war&lt;/strong&gt; : Ein Coding-Agent hatte das Token in einen Response Body geschrieben, und das Gateway protokollierte Response Bodies. Mit diesen Tokens richtete der Angreifer den Release-Workflow auf ein Lookalike-Repository, und die offizielle Pipeline baute, signierte und veröffentlichte das Ergebnis. Eine gültige Signatur half also nicht.&lt;/p&gt;

&lt;p&gt;Was daraus folgt, egal welches Gateway Sie betreiben:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per Digest pinnen, bewusst aktualisieren.&lt;/strong&gt; &lt;code&gt;latest&lt;/code&gt; und &lt;code&gt;main-stable&lt;/code&gt; gehörten zu den vergifteten Tags; das Benchmark-Repository pinnt jedes Image per &lt;code&gt;sha256&lt;/code&gt;. Pinnen heißt nicht “nie aktualisieren”: LiteLLM vermutet als Einstieg in die interne Instanz eine kürzlich gepatchte Schwachstelle. Die &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/security/npm-lieferkettenangriffe-abwehren-2026" rel="noopener noreferrer"&gt;Checkliste gegen npm-Lieferkettenangriffe&lt;/a&gt; gilt für PyPI fast wörtlich.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protokollierte Prompts und Antworten sind Geheimnisse.&lt;/strong&gt; Was durch das Gateway läuft, landet in seinen Logs: von Nutzern eingefügte Tokens, von Agenten zurückgegebene Schlüssel, personenbezogene Daten. Vor dem Speichern schwärzen, kurze Aufbewahrung, wenige Leserechte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Die Admin-Seite des Gateways ist das Kronjuwel.&lt;/strong&gt; Keine Admin-Oberfläche oder -API im Internet, SSO davor und ein Alarm bei jedem neuen Nutzer oder Schlüssel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anbieterschlüssel möglichst nicht in der Datenbank des Gateways.&lt;/strong&gt; Zur Laufzeit aus einem Secret Manager lesen und rotieren; ein gestohlener Datenbank-Dump ist dann deutlich weniger wert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dem Gateway einen eigenen, engen Netzwerkpfad geben.&lt;/strong&gt; Es muss die Anbieter erreichen, nicht Ihr ganzes Cloud-Konto oder den Metadatendienst der Instanz.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wenn Sie 1.107.0 irgendwo installiert hatten,&lt;/strong&gt; folgen Sie dem Incident Report und rotieren Sie jeden Schlüssel, den diese Maschine lesen konnte, nicht nur die Anbieterschlüssel.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fazit
&lt;/h2&gt;

&lt;p&gt;Ein AI Gateway oder LLM Gateway ist keine neue Idee. Es ist das Muster des API Gateways, angewandt auf Verkehr, der pro Token abgerechnet wird, minutenlang streamt und zwischen Anbietern ausweicht. Wenn das zweite Team, der zweite Anbieter oder die erste Compliance-Frage kommt, zahlt es sich aus.&lt;/p&gt;

&lt;p&gt;Der Benchmark beziffert die Angst vor dem zusätzlichen Hop: Ein Gateway mit ausreichender Kapazität kostet eine oder wenige Millisekunden, weit weniger, als jedes Modell zum Antworten braucht. Was die Optionen trennt, ist, wie viel Verkehr eine Replika trägt, wie sie streamt, wo sie läuft und wer die Schlüssel hält.&lt;/p&gt;

&lt;p&gt;Um es auf Ihrer eigenen Hardware zu messen, startet das &lt;a href="https://github.com/LF3551/ai-gateway-benchmark" rel="noopener noreferrer"&gt;Benchmark-Repository&lt;/a&gt; alles mit Docker in drei Befehlen.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ursprünglich veröffentlicht auf &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/ai/ai-gateway-llm-gateway-erklaert-latenz-benchmark" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aigateway</category>
      <category>llmgateway</category>
      <category>llmproxy</category>
      <category>litellm</category>
    </item>
    <item>
      <title>TypeScript 7 vs. TypeScript 6: Was sich ändert, was kaputtgeht und wie das Upgrade gelingt</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/typescript-7-vs-typescript-6-was-sich-andert-was-kaputtgeht-und-wie-das-upgrade-gelingt-6dp</link>
      <guid>https://dev.to/aleksei_aleinikov/typescript-7-vs-typescript-6-was-sich-andert-was-kaputtgeht-und-wie-das-upgrade-gelingt-6dp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdf9eyx21vywe91sjglbo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdf9eyx21vywe91sjglbo.webp" alt="TypeScript 7 vs. TypeScript 6: Was sich ändert, was kaputtgeht und wie das Upgrade gelingt" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Über ein Jahrzehnt lang war &lt;code&gt;tsc&lt;/code&gt; ein JavaScript-Programm, das JavaScript-Programme prüft, und jedes Team mit einem großen Monorepo kennt die Grenzen: ein Check von 30 Sekunden, ein Editor, der eine Minute zum Aufwachen braucht, ein CI-Job, der 8 GB Heap reserviert. &lt;strong&gt;TypeScript 7&lt;/strong&gt; ersetzt diese Engine. Compiler und Language Service sind jetzt ein natives Programm in Go, die Sprache bleibt unverändert, und &lt;code&gt;npm i -D typescript&lt;/code&gt; installiert es.&lt;/p&gt;

&lt;p&gt;„Dieselbe Sprache, nur schneller” ist das Versprechen, und es stimmt größtenteils. Das „größtenteils” ist der interessante Teil. Dieser Leitfaden vergleicht TypeScript 7 und TypeScript 6 direkt, zeigt einen Benchmark mit echten Open-Source-Projekten, darunter einen Fall, in dem beide Compiler nicht übereinstimmen, listet jede Option, die zum harten Fehler wurde, und beschreibt einen Upgrade-Weg, der dein Build nicht an einem Freitag zerlegt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdf9eyx21vywe91sjglbo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdf9eyx21vywe91sjglbo.webp" alt="TypeScript 7 vs. 6: dieselbe Sprache auf einem neuen nativen Compiler, gemessen an echten Open-Source-Projekten." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TypeScript 7 vs. 6 auf einen Blick
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;TypeScript 6.0&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;TypeScript 7.0&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Veröffentlicht&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;23. März 2026 (6.0.3 am 16. April)&lt;/td&gt;
&lt;td&gt;8. Juli 2026 (Beta 21. April, RC 18. Juni)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Geschrieben in&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TypeScript/JavaScript, läuft auf Node.js&lt;/td&gt;
&lt;td&gt;Go, natives Binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rolle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Letztes Release auf der JS-Codebasis, die Brücke von 5.9&lt;/td&gt;
&lt;td&gt;Der neue Standard-Compiler und Language Service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Parallelität&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ein Thread&lt;/td&gt;
&lt;td&gt;Paralleles Parsen, Prüfen und Emittieren (&lt;code&gt;--checkers&lt;/code&gt;, &lt;code&gt;--builders&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Veraltete Optionen&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Warnung; &lt;code&gt;ignoreDeprecations: "6.0"&lt;/code&gt; unterdrückt sie&lt;/td&gt;
&lt;td&gt;Harte Fehler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compiler-API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ja (&lt;code&gt;import ts from "typescript"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Nicht in 7.0; neue API in 7.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Language Services für Vue, Svelte, Astro, MDX, Angular&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Funktionieren&lt;/td&gt;
&lt;td&gt;Noch nicht&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Installation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;typescript@6&lt;/code&gt; oder &lt;code&gt;@typescript/typescript6&lt;/code&gt; (&lt;code&gt;tsc6&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;typescript@latest&lt;/code&gt; (zum Zeitpunkt des Schreibens 7.0.2)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Die Namen sorgen für den Großteil der Verwirrung, deshalb genau: &lt;strong&gt;TypeScript 6.0 ist nicht „die langsame Version”.&lt;/strong&gt; Es ist das Release, das den Boden bereitet hat: Es hat Standardwerte geändert, die Altoptionen als veraltet markiert und ein Flag für deterministische Diagnosen ergänzt, damit 7.0 ein geradliniger Port sein konnte. Die ausführlichen Ankündigungen stehen im Blog des TypeScript-Teams für &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/" rel="noopener noreferrer"&gt;6.0&lt;/a&gt; und &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/" rel="noopener noreferrer"&gt;7.0&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warum 7 schneller ist: nativer Code und parallele Worker
&lt;/h2&gt;

&lt;p&gt;Ein TypeScript-Build hat vier Stufen: Dateien &lt;strong&gt;parsen&lt;/strong&gt; , Namen an Symbole &lt;strong&gt;binden&lt;/strong&gt; , Typen &lt;strong&gt;prüfen&lt;/strong&gt; und Ausgabe &lt;strong&gt;emittieren&lt;/strong&gt;. In TypeScript 6 läuft alles in einem JavaScript-Thread unter dem Speicherlimit des V8-Heaps. Das Prüfen ist der teure Teil, und zugleich der, der sich am schwersten aufteilen ließ.&lt;/p&gt;

&lt;p&gt;TypeScript 7 ändert zwei Dinge gleichzeitig. Der Code ist nativ: kein JIT-Warmup und kein garbage-collecteter Heap aus JavaScript-Objekten. Und die Stufen teilen sich den Speicher, sodass die Arbeit auf Threads verteilt werden kann:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6qc9ljvf41lk88nfxoy4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6qc9ljvf41lk88nfxoy4.webp" alt="TypeScript 7 vs. 6 Build-Pipeline: ein JavaScript-Thread gegen nativen Code mit parallelem Parsen, vier Checker-Workern und parallelem Emit, plus die Flags, die das steuern." width="799" height="493"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Die Geschwindigkeit kommt aus nativem Code plus Parallelität. Beides lässt sich einstellen, und beides kostet Speicher, wenn man aufdreht.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Drei Flags steuern die Parallelität (die Release-Notes führen &lt;code&gt;--checkers&lt;/code&gt; und &lt;code&gt;--builders&lt;/code&gt; als experimentell):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--checkers N&lt;/code&gt;&lt;/strong&gt; legt die Zahl der Type-Checking-Worker fest (Standard 4). Mehr Worker brauchen mehr Kerne und mehr Speicher, weil jeder seine eigene Sicht auf das Programm hat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--builders N&lt;/code&gt;&lt;/strong&gt; baut Projekt-Referenzen parallel, unter &lt;code&gt;tsc --build&lt;/code&gt;. Es multipliziert sich mit &lt;code&gt;--checkers&lt;/code&gt;: 4 Builder mit 4 Checkern können 16 Worker bedeuten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--singleThreaded&lt;/code&gt;&lt;/strong&gt; schaltet alles ab, nützlich auf kleinen CI-Runnern und für den Benchmark unten.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wenn dich interessiert, wie eine native Laufzeit solche Arbeit auf OS-Threads verteilt: Die Go-Seite davon behandelt &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/programming/wie-viele-threads-nutzt-go-wirklich-runtime-untersuchung-2026" rel="noopener noreferrer"&gt;Wie viele Threads nutzt Go wirklich?&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Der Watch-Modus wurde auf einem nativen File-Watcher neu gebaut, und der Language Service hat dieselbe Behandlung bekommen. Die von Microsoft gemeldeten Zahlen aus dem &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/" rel="noopener noreferrer"&gt;Release-Post&lt;/a&gt; sind gut zu kennen, mit dem Vorbehalt, dass sie von Microsoft und von großen Codebasen stammen: „typischerweise 8- bis 12-mal” bei Full Builds, die Zeit bis zum ersten Fehler in VS Code von 17,5 s auf unter 1,3 s, Slacks CI-Type-Check von 7,5 auf 1,25 Minuten, und bei Canva die Zeit bis zum ersten Fehler im Editor von 58 auf 4,8 Sekunden.&lt;/p&gt;
&lt;h2&gt;
  
  
  Der Benchmark: drei echte Projekte, drei Compiler
&lt;/h2&gt;

&lt;p&gt;In den Release-Notes steht „10-mal schneller”. Ich wollte das an Code sehen, den ich nicht selbst geschrieben habe, also habe ich drei beliebte Open-Source-Projekte genommen, jedes mit TypeScript 6.0.3, TypeScript 7.0.2 (Standard und &lt;code&gt;--singleThreaded&lt;/code&gt;) und der 7.1.0-Beta vom 20. Oktober geprüft und jeden Lauf gestoppt. Der gesamte Aufbau ist Open Source, du kannst ihn also auf deiner eigenen Maschine wiederholen: &lt;a href="https://github.com/LF3551/typescript-7-vs-6-benchmark" rel="noopener noreferrer"&gt;typescript-7-vs-6-benchmark auf GitHub&lt;/a&gt;. Jeder Lauf war &lt;code&gt;tsc -p &amp;lt;tsconfig&amp;gt; --noEmit&lt;/code&gt;: nur Type-Checking, ohne Emit.&lt;/p&gt;

&lt;p&gt;Die Testkandidaten, zuerst der Stresstest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;type-fest&lt;/strong&gt; , eine Sammlung von Typ-Utilities, die fast ihre gesamte Zeit mit Typ-Berechnung verbringt. Rekursive bedingte Typen bis zum Anschlag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kysely&lt;/strong&gt; , ein typsicherer SQL-Query-Builder. Eine typische Bibliothek mit vielen Generics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hono&lt;/strong&gt; , ein Web-Framework, zweimal geprüft: nur der Quellcode und Quellcode plus Tests. Bei den Tests beginnen die beiden Compiler, sich zu widersprechen, wie du unten siehst.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Jede Konfiguration lief mehrfach in getrennten Prozessen mit Node.js 24. Der erste Lauf wurde als Warm-up verworfen, die Tabelle zeigt den Median der übrigen. Alle Compiler bekamen denselben großzügigen Node-Heap. Die 7.1-Beta hatte bei type-fest ein paar Dateien mehr im Programm, lies ihre Spalte also als nahen, nicht als exakten Vergleich.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Projekt&lt;/th&gt;
&lt;th&gt;TS 6.0.3&lt;/th&gt;
&lt;th&gt;TS 7.0.2 &lt;code&gt;--singleThreaded&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;TS 7.0.2 Standard&lt;/th&gt;
&lt;th&gt;TS 7.1.0-beta&lt;/th&gt;
&lt;th&gt;Speedup 7.0.2 vs. 6.0.3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;type-fest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;23,9 s&lt;/td&gt;
&lt;td&gt;16,4 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8,1 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4,6 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,9×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;kysely&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,37 s&lt;/td&gt;
&lt;td&gt;0,48 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0,27 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0,22 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5,1×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;hono&lt;/strong&gt; (Quellcode + Tests)&lt;/td&gt;
&lt;td&gt;0,82 s&lt;/td&gt;
&lt;td&gt;0,34 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0,16 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0,15 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5,0×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;hono&lt;/strong&gt; (nur Quellcode)&lt;/td&gt;
&lt;td&gt;0,46 s&lt;/td&gt;
&lt;td&gt;0,17 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0,10 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0,10 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,4×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frioj0yozieljxvja7xw9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frioj0yozieljxvja7xw9.webp" alt="TypeScript 7 vs. 6 Benchmark mit type-fest, kysely und hono: Type-Checks sind mit TypeScript 7.0.2 2,9- bis 5,1-mal schneller, die Single-Thread-Balken zeigen, wie viel allein vom nativen Code kommt." width="799" height="513"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Der native Code allein bringt das 1,5- bis 2,9-Fache. Vier parallele Checker legen noch einmal das 1,6- bis 2,1-Fache obendrauf.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Was die Zahlen sagen, ohne sie schönzurechnen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3- bis 5-mal, nicht 10-mal.&lt;/strong&gt; Das liegt unter den 8–12 aus der Ankündigung, und ich würde die Tabelle nicht passend biegen. Microsofts Zahlen gelten für Full Builds großer Repositories, die Projekte hier sind kleiner. type-fest ist der Extremfall: ein Haufen rekursiver bedingter Typen, die Zeit geht fast nur in die Typ-Berechnung, und trotzdem war es fast dreimal so schnell. Eine faire Zusammenfassung lautet „je nach Codebasis grob 3- bis 12-mal”, und verlässlich einordnen kannst du deine nur durch Messen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Der native Code allein ist die größere Hälfte.&lt;/strong&gt; &lt;code&gt;--singleThreaded&lt;/code&gt; war 1,5- bis 2,9-mal schneller als TypeScript 6. Die vier Standard-Checker halbierten die Zeit dann ungefähr noch einmal (1,6- bis 2,1-mal).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript 6 kommt mit dem Standard-Node-Heap bei type-fest gar nicht durch.&lt;/strong&gt; Es lief bei etwa 4 GB Heap voll und brauchte ein erhöhtes &lt;code&gt;--max-old-space-size&lt;/code&gt;, die JavaScript-Obergrenze, die ein natives Binary nicht hat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7.1 ist noch einmal schneller.&lt;/strong&gt; Die Beta senkte type-fest von 8,1 s auf 4,6 s, also 5,1-mal schneller als 6.0.3, und war bei den anderen 4,7- bis 6,4-mal schneller als TypeScript 6.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beim Speicher ist das Bild gemischter. Maximaler Resident Memory in Megabyte:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Projekt&lt;/th&gt;
&lt;th&gt;TS 6.0.3&lt;/th&gt;
&lt;th&gt;TS 7.0.2 Standard&lt;/th&gt;
&lt;th&gt;TS 7.1.0-beta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;type-fest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6.196&lt;/td&gt;
&lt;td&gt;7.141&lt;/td&gt;
&lt;td&gt;5.838&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;kysely&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;460&lt;/td&gt;
&lt;td&gt;307&lt;/td&gt;
&lt;td&gt;222&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;hono&lt;/strong&gt; (Quellcode + Tests)&lt;/td&gt;
&lt;td&gt;440&lt;/td&gt;
&lt;td&gt;207&lt;/td&gt;
&lt;td&gt;165&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;hono&lt;/strong&gt; (nur Quellcode)&lt;/td&gt;
&lt;td&gt;327&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bei den kleinen und mittleren Projekten brauchte TypeScript 7 ein Drittel bis zwei Drittel weniger Speicher. Bei type-fest, mit der meisten Typ-Arbeit, brauchten vier Checker etwa 15 % mehr als der einzelne JavaScript-Thread, und die 7.1-Beta weniger als beide. Wenn deine CI-Runner speicherbegrenzt sind, ist &lt;code&gt;--checkers 2&lt;/code&gt; oder &lt;code&gt;--singleThreaded&lt;/code&gt; der erste Regler zum Ausprobieren.&lt;/p&gt;

&lt;p&gt;Achtung&lt;/p&gt;
&lt;p&gt;Schnell heißt nicht identische Ergebnisse. Bei hono’s Testsuite meldete &lt;code&gt;tsc 6.0.3&lt;/code&gt; &lt;strong&gt;0 Fehler&lt;/strong&gt; und &lt;code&gt;tsc 7.0.2&lt;/code&gt; &lt;strong&gt;4&lt;/strong&gt;: zwei ungenutzte &lt;code&gt;@ts-expect-error&lt;/code&gt;-Direktiven (TS2578) und zwei „No overload matches this call”-Fehler (TS2769, &lt;code&gt;string&lt;/code&gt; nicht zuweisbar an &lt;code&gt;string[]&lt;/code&gt;), alle in einer Testdatei. Die 7.1-Beta meldete 6, und bei hono’s reiner Quellcode-Konfiguration kamen mit der Beta 2 &lt;code&gt;noImplicitAny&lt;/code&gt;-Fehler hinzu, die 6.0 und 7.0 nicht meldeten. Jeder davon kann eine berechtigte Korrektur oder eine Regression sein, und in beiden Fällen willst du es in einem Branch herausfinden, nicht im Release.&lt;/p&gt;
&lt;p&gt;Hinweis&lt;/p&gt;
&lt;p&gt;Nachstellen in drei Befehlen: &lt;code&gt;git clone https://github.com/LF3551/typescript-7-vs-6-benchmark&lt;/code&gt;, dann &lt;code&gt;npm run setup&lt;/code&gt; und &lt;code&gt;npm run bench&lt;/code&gt;. Das &lt;a href="https://github.com/LF3551/typescript-7-vs-6-benchmark" rel="noopener noreferrer"&gt;Repository&lt;/a&gt; pinnt die drei Projekte an exakten Commits, installiert TypeScript 6.0.3, 7.0.2 und 7.1.0-beta in getrennten Ordnern, damit die Binaries sich nicht vermischen, und nutzt für alle Compiler dasselbe &lt;code&gt;NODE_OPTIONS=--max-old-space-size&lt;/code&gt;. Eigene Projekte trägst du in &lt;code&gt;config.json&lt;/code&gt; ein. Zahlen von einer einzelnen Maschine sind ein Plausibilitätscheck, keine Bestenliste; vergleiche Verhältnisse, nicht Sekunden.&lt;/p&gt;
&lt;h2&gt;
  
  
  Was TypeScript 6 für dich geändert hat
&lt;/h2&gt;

&lt;p&gt;Der Großteil der Upgrade-Arbeit passiert in 6.0, denn dort haben sich die Standardwerte verschoben. Wenn deine &lt;code&gt;tsconfig.json&lt;/code&gt; kurz ist und sich auf Defaults verlässt, sind das die, die beißen:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Einstellung&lt;/th&gt;
&lt;th&gt;Standard vor 6.0&lt;/th&gt;
&lt;th&gt;Standard in 6.0 und 7.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;module&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;commonjs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;esnext&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;es5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;die neueste stabile ECMAScript-Version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;types&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;alle &lt;code&gt;@types/*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[]&lt;/code&gt; (nichts wird automatisch eingebunden)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rootDir&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;aus den Eingabedateien abgeleitet&lt;/td&gt;
&lt;td&gt;der Ordner mit der &lt;code&gt;tsconfig.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;noUncheckedSideEffectImports&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;libReplacement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typ-Reihenfolge (&lt;code&gt;stableTypeOrdering&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;nicht deterministisch&lt;/td&gt;
&lt;td&gt;in 6.0 optionales Flag, in 7.0 immer an&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zwei davon verdienen Aufmerksamkeit. &lt;strong&gt;&lt;code&gt;types: []&lt;/code&gt;&lt;/strong&gt; heißt: &lt;code&gt;process&lt;/code&gt;, &lt;code&gt;describe&lt;/code&gt; und jedes andere globale Symbol aus &lt;code&gt;@types/node&lt;/code&gt; oder &lt;code&gt;@types/jest&lt;/code&gt; verschwindet, bis du es auflistest, der häufigste erste Fehler in einem hochgezogenen Projekt. Der Vorteil ist real: Die 6.0-Ankündigung nennt, dass viele Projekte 20–50 % Build-Zeit gewannen, nur weil sie &lt;code&gt;types&lt;/code&gt; explizit setzten. Und &lt;strong&gt;&lt;code&gt;stableTypeOrdering&lt;/code&gt;&lt;/strong&gt; macht die Reihenfolge deterministisch, in der der Checker Typen entdeckt; in 6.0 kannst du es per Flag einschalten (es kostet bis zu 25 % Check-Zeit, ist also zur Diagnose deiner Migration da, nicht für den Alltag). In 7.0 ist es immer an und lässt sich nicht abschalten, es ist also eine der Stellen, an denen Ergebnisse von dem abweichen können, was du auf 6.0 gesehen hast.&lt;/p&gt;

&lt;p&gt;Eine tsconfig, die auf beiden Versionen läuft, sieht so aus:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;tsconfig.json&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"es2022"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"esnext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bundler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"noEmit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;6.0 brachte außerdem Features, die man kennen sollte und die alle auch auf 7 verfügbar sind: &lt;code&gt;#/&lt;/code&gt;-Subpath-Imports, &lt;code&gt;bundler&lt;/code&gt;-Resolution zusammen mit &lt;code&gt;commonjs&lt;/code&gt;, das Target und die Bibliothek &lt;code&gt;es2025&lt;/code&gt;, Temporal-Typen, &lt;code&gt;Map.getOrInsert&lt;/code&gt; und &lt;code&gt;RegExp.escape&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was in TypeScript 7 kaputtgeht
&lt;/h2&gt;

&lt;p&gt;In 6.0 wurde eine Reihe langjähriger Optionen als veraltet markiert und konnte mit &lt;code&gt;"ignoreDeprecations": "6.0"&lt;/code&gt; stummgeschaltet werden. In 7.0 gibt es diesen Schalter nicht mehr, und die Optionen sind &lt;strong&gt;harte Fehler&lt;/strong&gt; :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option oder Syntax&lt;/th&gt;
&lt;th&gt;Warum sie geht&lt;/th&gt;
&lt;th&gt;Stattdessen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target: "es5"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Veraltetes Ausgabeziel&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;es2015&lt;/code&gt; oder neuer als Target, und einen Bundler herunterkompilieren lassen, falls du wirklich alte Browser brauchst&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;downlevelIteration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nur für Targets unter ES2015 nötig&lt;/td&gt;
&lt;td&gt;Entfernen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;moduleResolution: "node10"&lt;/code&gt; (&lt;code&gt;node&lt;/code&gt;) und &lt;code&gt;"classic"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Auflösung aus der Zeit vor ESM&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bundler&lt;/code&gt;, &lt;code&gt;node16&lt;/code&gt; oder &lt;code&gt;nodenext&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;module: "amd"&lt;/code&gt;, &lt;code&gt;"umd"&lt;/code&gt;, &lt;code&gt;"systemjs"&lt;/code&gt;, &lt;code&gt;"none"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Überholte Modulformate&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;esnext&lt;/code&gt; oder &lt;code&gt;preserve&lt;/code&gt; mit Bundler, oder &lt;code&gt;commonjs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Löst bloße Imports ab der Projektwurzel auf&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;paths&lt;/code&gt; mit expliziten Zuordnungen oder &lt;code&gt;#/&lt;/code&gt;-Subpath-Imports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;outFile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;In 6.0 entfernt&lt;/td&gt;
&lt;td&gt;Ein Bundler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;esModuleInterop: false&lt;/code&gt;, &lt;code&gt;allowSyntheticDefaultImports: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Interop ist jetzt immer an&lt;/td&gt;
&lt;td&gt;Einstellung entfernen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alwaysStrict: false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Strict Mode ist immer an&lt;/td&gt;
&lt;td&gt;Einstellung entfernen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;module Foo {}&lt;/code&gt; für Namespaces&lt;/td&gt;
&lt;td&gt;Mehrdeutiges Schlüsselwort&lt;/td&gt;
&lt;td&gt;&lt;code&gt;namespace Foo {}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;import x from "y" assert { type: "json" }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Durch &lt;code&gt;with&lt;/code&gt; ersetzt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;with { type: "json" }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsc datei.ts&lt;/code&gt;, obwohl eine &lt;code&gt;tsconfig.json&lt;/code&gt; existiert&lt;/td&gt;
&lt;td&gt;Man vergisst leicht, dass die Config ignoriert wird&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tsc --ignoreConfig datei.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Die meisten davon sind Ein-Zeilen-Löschungen. &lt;code&gt;baseUrl&lt;/code&gt; ist die Option, die Imports anfasst, und ein kleines Tool, &lt;code&gt;ts5to6&lt;/code&gt;, schreibt &lt;code&gt;baseUrl&lt;/code&gt; und &lt;code&gt;rootDir&lt;/code&gt; für dich um.&lt;/p&gt;

&lt;p&gt;Jenseits der Konfiguration haben sich ein paar Verhalten bewusst geändert: Die Inferenz von Template-Literal-Typen behandelt Strings jetzt nach Codepoints, und die JavaScript-Unterstützung hat etwas alte JSDoc- und Closure-Syntax verloren (&lt;code&gt;@enum&lt;/code&gt;, &lt;code&gt;@class&lt;/code&gt;, Postfix-&lt;code&gt;!&lt;/code&gt;, Closure-Funktionstypen). Beides trifft den meisten TypeScript-Code nicht, aber es sind die Stellen, an denen du suchst, wenn nach der Umstellung ein Test ohne Config-Fehler fehlschlägt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fryvrg3hhvo772vquqyr8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fryvrg3hhvo772vquqyr8.webp" alt="TypeScript-Upgrade-Weg von 5.9 über 6.0.3 zu 7.0 und 7.1, mit den Optionen, die zu harten Fehlern werden, und den Tools, die heute umstellen können." width="799" height="433"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Räum die Deprecations auf 6.0 auf, stelle dann die CI auf den nativen Compiler um und lass Tools, die die Compiler-API brauchen, auf 7.1 warten.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Tooling: Was heute mit 7 funktioniert
&lt;/h2&gt;

&lt;p&gt;Hier trifft „dieselbe Sprache” auf die Realität. TypeScript 7.0 wird &lt;strong&gt;ohne programmatische API&lt;/strong&gt; ausgeliefert, weil der Compiler keine JavaScript-Bibliothek mehr ist, die man per &lt;code&gt;require&lt;/code&gt; laden kann. Die Folge ist klar:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Was&lt;/th&gt;
&lt;th&gt;Funktioniert auf 7.0?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsc&lt;/code&gt; im Terminal und in der CI&lt;/td&gt;
&lt;td&gt;Ja&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type-Checking im Editor für reines TS und TSX&lt;/td&gt;
&lt;td&gt;Ja, über den TypeScript-7-Language-Server (eine VS-Code-Erweiterung; Visual Studio aktiviert ihn pro Workspace)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools, die &lt;code&gt;import "typescript"&lt;/code&gt; nutzen (typescript-eslint, Codemods, Doku-Generatoren)&lt;/td&gt;
&lt;td&gt;Nicht direkt; nutze den 6.0-Alias&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language Services für Vue-, MDX-, Astro-, Svelte-, Angular-Templates&lt;/td&gt;
&lt;td&gt;Noch nicht&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--watch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ja, auf einem nativen Watcher neu gebaut&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Der Workaround ist, beide zu betreiben. Das Paket &lt;code&gt;@typescript/typescript6&lt;/code&gt; liefert den 6.0-Compiler mit einem &lt;code&gt;tsc6&lt;/code&gt;-Binary, und mit npm-Aliassen bekommt alles, was &lt;code&gt;typescript&lt;/code&gt; importiert, weiter 6.0, während der native Compiler unter eigenem Namen liegt:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;package.json&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"devDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"typescript"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:@typescript/typescript6@^6.0.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"@typescript/native"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:typescript@^7.0.2"&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;em&gt;Beide parallel betreiben, solange du migrierst&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
npm i &lt;span class="nt"&gt;-D&lt;/span&gt; typescript@7 @typescript/typescript6

npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt; &lt;span class="c"&gt;# natives TypeScript 7&lt;/span&gt;

npx tsc6 &lt;span class="nt"&gt;--noEmit&lt;/span&gt; &lt;span class="c"&gt;# TypeScript 6.0 zum Vergleich&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Das installiert beide Kommandozeilen-Tools. Nutze stattdessen die Alias-Form von oben, wenn Tools wie typescript-eslint brauchen, dass &lt;code&gt;import "typescript"&lt;/code&gt; zu 6.0 auflöst. In VS Code schaltet der Befehl „Disable TypeScript 7 Language Server” den Editor zurück auf 6.0, falls dir der Language Server mal im Weg steht.&lt;/p&gt;

&lt;p&gt;Tipp&lt;/p&gt;

&lt;p&gt;Lass &lt;code&gt;tsc6&lt;/code&gt; einige Wochen in der CI mitlaufen und vergleiche die Ausgaben. Jede Datei, in der die beiden abweichen, ist entweder ein Fehler, den du jetzt kennen willst, oder einer, den du melden willst; das hono-Ergebnis oben ist genau der Fall, den das findet.&lt;/p&gt;

&lt;p&gt;Dieselbe Disziplin „alt und neu parallel laufen lassen” macht auch andere Toolchain-Wechsel schmerzfrei. So würde ich den &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/programming/biome-2026-eslint-prettier-ersetzen-benchmark" rel="noopener noreferrer"&gt;Ersatz von ESLint und Prettier durch Biome&lt;/a&gt; angehen, und sie hat die &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/programming/von-jest-zu-vitest-migrieren" rel="noopener noreferrer"&gt;Migration von Jest zu Vitest&lt;/a&gt; berechenbar gemacht.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ein sicherer Upgrade-Weg
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Auf TypeScript 6.0.3 upgraden und pinnen.&lt;/strong&gt; Behebe die auftretenden Fehler, meist fehlende &lt;code&gt;types&lt;/code&gt;-Einträge und &lt;code&gt;strict&lt;/code&gt;-Folgen, und committe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jede Deprecation-Warnung aufräumen.&lt;/strong&gt; Führe &lt;code&gt;tsc&lt;/code&gt; aus und behandle jede Deprecation wie einen Fehler; nutze &lt;code&gt;ts5to6&lt;/code&gt; für &lt;code&gt;baseUrl&lt;/code&gt; und &lt;code&gt;rootDir&lt;/code&gt;. Greif nicht zu &lt;code&gt;ignoreDeprecations&lt;/code&gt; als Endlösung, denn auf 7 bewirkt es nichts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;types&lt;/code&gt; und &lt;code&gt;rootDir&lt;/code&gt; explizit setzen.&lt;/strong&gt; Das ist der billigste Speedup der ganzen Migration und beseitigt die beiden Standardwerte, die sich am stärksten geändert haben.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional 6.0 mit &lt;code&gt;--stableTypeOrdering&lt;/code&gt; laufen lassen.&lt;/strong&gt; Es macht die 6.0-Diagnosen auf dieselbe Weise deterministisch wie 7.0, sodass jeder spätere Unterschied vom Port kommt und nicht von der Reihenfolge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript 7 neben 6 in die CI nehmen.&lt;/strong&gt; Lass &lt;code&gt;tsc&lt;/code&gt; und &lt;code&gt;tsc6&lt;/code&gt; laufen, lass den Build an &lt;code&gt;tsc6&lt;/code&gt; scheitern und melde &lt;code&gt;tsc&lt;/code&gt; als nicht blockierenden Job. Lies jede Abweichung.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Den blockierenden Check auf 7 umstellen.&lt;/strong&gt; Behalte den 6.0-Alias für Editoren und jedes Tool, das &lt;code&gt;typescript&lt;/code&gt; importiert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Die Parallelität abstimmen.&lt;/strong&gt; Beginne mit den vier Standard-Checkern; nutze &lt;code&gt;--checkers 2&lt;/code&gt; oder &lt;code&gt;--singleThreaded&lt;/code&gt; auf speicherbegrenzten Runnern und &lt;code&gt;--builders&lt;/code&gt; bei Monorepos mit Projekt-Referenzen. Lege denselben &lt;code&gt;--checkers&lt;/code&gt;-Wert auf jeder Maschine und in der CI fest, denn die Release-Notes warnen, dass eine Änderung in seltenen Fällen reihenfolgeabhängige Ergebnisse sichtbar machen kann.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Wenn du eine Bibliothek pflegst, die die Compiler-API anbietet, oder ein Projekt mit Vue-, Svelte-, Astro- oder Angular-Templates, bleib vorerst bei Schritt 5 stehen. Das ist eine Entscheidung, kein Scheitern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was TypeScript 7.1 bringt
&lt;/h2&gt;

&lt;p&gt;TypeScript 7.1 erreichte am &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-1-beta/" rel="noopener noreferrer"&gt;20. Oktober 2026 die Beta&lt;/a&gt;, installierbar als &lt;code&gt;typescript@beta&lt;/code&gt;, ein Release Candidate wird etwa einen Monat später erwartet. Es ist das Release, das die meisten der obigen Lücken schließt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Eine neue Compiler-API&lt;/strong&gt; mit Einstiegspunkten wie &lt;code&gt;typescript/sync&lt;/code&gt;, &lt;code&gt;typescript/ast&lt;/code&gt; und &lt;code&gt;typescript/fs&lt;/code&gt;, Snapshots, Dateisystem-Schichten und Unterstützung für &lt;code&gt;using&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content Mapper&lt;/strong&gt; , konfiguriert mit &lt;code&gt;contentMappers&lt;/code&gt; in der &lt;code&gt;tsconfig.json&lt;/code&gt;, für Nicht-TypeScript-Dateien wie Vue, Astro, Svelte und MDX, mit &lt;code&gt;tsc --runExternalCode&lt;/code&gt; zum Ausführen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import Attributes in Ambient Modules&lt;/strong&gt; , die Syntax &lt;code&gt;import source&lt;/code&gt;, das Target &lt;code&gt;es2026&lt;/code&gt; und neue Bibliotheksmethoden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weitere Optimierungen&lt;/strong&gt; , im Benchmark oben sichtbar, und strengeres Prüfen distributiver Typparameter, das in typlastigem Code neue Fehler sichtbar machen kann. Die zusätzlichen hono-Diagnosen habe ich keiner konkreten Änderung zugeordnet, ihre Ursache ist also offen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Welche Version solltest du nutzen?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deine Situation&lt;/th&gt;
&lt;th&gt;Nutze&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reine TypeScript- oder React-App, CI-Type-Check ist langsam&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;7.0 für die CI&lt;/strong&gt; jetzt, nach einem Parallel-Lauf&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Großes Monorepo mit Projekt-Referenzen&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;7.0&lt;/strong&gt; , mit &lt;code&gt;--builders&lt;/code&gt; und &lt;code&gt;--checkers&lt;/code&gt; passend zu deinen Runnern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vue-, Svelte-, Astro-, MDX- oder Angular-Templates&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6.0 für den Editor&lt;/strong&gt; , 7.0 für CLI-Checks, auf 7.1 warten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bibliothek oder Tool, das &lt;code&gt;typescript&lt;/code&gt; importiert&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6.0&lt;/strong&gt; , bis die 7.1-API stabil ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noch auf &lt;code&gt;target es5&lt;/code&gt;, &lt;code&gt;baseUrl&lt;/code&gt;, &lt;code&gt;node10&lt;/code&gt;-Resolution oder AMD/UMD&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Zuerst 6.0&lt;/strong&gt; , Deprecations aufräumen, dann 7.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speicherbegrenzte CI-Runner&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.0 mit &lt;code&gt;--checkers 2&lt;/code&gt; oder &lt;code&gt;--singleThreaded&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Fazit
&lt;/h2&gt;

&lt;p&gt;TypeScript 7 ist dieselbe Sprache mit einer neuen Engine, und die Engine ist das, was du bemerkst: In diesem Test waren Type-Checks 2,9- bis 5,1-mal schneller als auf TypeScript 6, laut Microsofts eigenen Zahlen mit größerem Gewinn bei größeren Codebasen, und die 7.1-Beta ist noch schneller. Der Preis ist eine Migration mit Kanten. Jede Option, die 6.0 als veraltet markierte, ist jetzt ein Fehler, die Compiler-API und die Template-Language-Services müssen auf 7.1 warten, und bei mindestens einem bekannten Projekt sind sich die beiden Compiler über denselben Code uneins. Also: über 6.0 gehen, beide Compiler parallel laufen lassen, zuerst die CI umstellen und den Rest der Toolchain, wenn ihre Unterstützung da ist. Dann dauert das Upgrade einen Nachmittag Konfigurationsarbeit, und du bekommst deine Build-Minuten zurück.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ursprünglich veröffentlicht auf &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/programming/typescript-7-vs-typescript-6-nativer-compiler-upgrade-leitfaden" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript7</category>
      <category>typescript6</category>
      <category>tsgo</category>
      <category>typescriptgo</category>
    </item>
    <item>
      <title>TypeScript 7 vs TypeScript 6: What Changed, What Breaks and How to Upgrade</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/typescript-7-vs-typescript-6-what-changed-what-breaks-and-how-to-upgrade-16ek</link>
      <guid>https://dev.to/aleksei_aleinikov/typescript-7-vs-typescript-6-what-changed-what-breaks-and-how-to-upgrade-16ek</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdf9eyx21vywe91sjglbo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdf9eyx21vywe91sjglbo.webp" alt="TypeScript 7 vs TypeScript 6: What Changed, What Breaks and How to Upgrade" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For over a decade &lt;code&gt;tsc&lt;/code&gt; has been a JavaScript program type-checking JavaScript programs, and every team with a big monorepo has learned its limits: a 30-second check, an editor that needs a minute to wake up, a CI job that reserves 8 GB of heap. &lt;strong&gt;TypeScript 7&lt;/strong&gt; replaces that engine. The compiler and the language service are now a native program written in Go, the language is unchanged, and &lt;code&gt;npm i -D typescript&lt;/code&gt; installs it.&lt;/p&gt;

&lt;p&gt;“Same language, faster” is the pitch, and it is mostly true. Mostly is the interesting part. This guide compares TypeScript 7 and TypeScript 6 side by side, shows a benchmark on real open-source projects including a case where the two compilers disagree, lists every option that turned into a hard error, and gives an upgrade path that does not break your build on a Friday.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdf9eyx21vywe91sjglbo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdf9eyx21vywe91sjglbo.webp" alt="TypeScript 7 vs 6: same language on a new native compiler, measured on real open-source projects." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TypeScript 7 vs 6 at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;TypeScript 6.0&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;TypeScript 7.0&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Released&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;23 March 2026 (6.0.3 on 16 April)&lt;/td&gt;
&lt;td&gt;8 July 2026 (beta 21 April, RC 18 June)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Written in&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TypeScript/JavaScript, runs on Node.js&lt;/td&gt;
&lt;td&gt;Go, native binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Role&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Last JS-codebase release, the bridge from 5.9&lt;/td&gt;
&lt;td&gt;The new default compiler and language service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Parallelism&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One thread&lt;/td&gt;
&lt;td&gt;Parallel parse, check and emit (&lt;code&gt;--checkers&lt;/code&gt;, &lt;code&gt;--builders&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deprecated options&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Warn; &lt;code&gt;ignoreDeprecations: "6.0"&lt;/code&gt; silences them&lt;/td&gt;
&lt;td&gt;Hard errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compiler API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;import ts from "typescript"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Not in 7.0; new API in 7.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vue, Svelte, Astro, MDX, Angular language services&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Work&lt;/td&gt;
&lt;td&gt;Not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Install&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;typescript@6&lt;/code&gt; or &lt;code&gt;@typescript/typescript6&lt;/code&gt; (&lt;code&gt;tsc6&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;typescript@latest&lt;/code&gt; (7.0.2 at time of writing)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The names are the source of most confusion, so it helps to be precise. &lt;strong&gt;TypeScript 6.0 is not “the slow one”&lt;/strong&gt;. It is the release that prepared the ground: it changed defaults, deprecated the legacy options and added a flag to make diagnostics deterministic, so that 7.0 could be a straight port. The detailed announcements are on the TypeScript team’s blog for &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/" rel="noopener noreferrer"&gt;6.0&lt;/a&gt; and &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/" rel="noopener noreferrer"&gt;7.0&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 7 is faster: native code and parallel workers
&lt;/h2&gt;

&lt;p&gt;A TypeScript build has four stages: &lt;strong&gt;parse&lt;/strong&gt; the files, &lt;strong&gt;bind&lt;/strong&gt; names to symbols, &lt;strong&gt;check&lt;/strong&gt; types, and &lt;strong&gt;emit&lt;/strong&gt; output. In TypeScript 6 all of it runs in one JavaScript thread, under the memory limit of the V8 heap. Type-checking is the expensive part, and it is also the part that was hardest to split up.&lt;/p&gt;

&lt;p&gt;TypeScript 7 changes two things at once. The code is native, so no JIT warm-up and no garbage-collected heap of JavaScript objects. And the stages share memory, so work can be split across threads:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6qc9ljvf41lk88nfxoy4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6qc9ljvf41lk88nfxoy4.webp" alt="TypeScript 7 vs 6 build pipeline: one JavaScript thread against native code with parallel parse, four checker workers and parallel emit, and the flags that control it." width="799" height="493"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The speed comes from native code plus parallelism. Both are tunable, and both cost memory when you turn them up.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three flags control the parallelism (the release notes mark &lt;code&gt;--checkers&lt;/code&gt; and &lt;code&gt;--builders&lt;/code&gt; as experimental):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--checkers N&lt;/code&gt;&lt;/strong&gt; sets the number of type-checking workers (default 4). More workers use more cores and more memory, because each has its own view of the program.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--builders N&lt;/code&gt;&lt;/strong&gt; builds project references in parallel under &lt;code&gt;tsc --build&lt;/code&gt;. It multiplies with &lt;code&gt;--checkers&lt;/code&gt;: 4 builders with 4 checkers can mean 16 workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--singleThreaded&lt;/code&gt;&lt;/strong&gt; turns it all off, which is useful on small CI runners and for the benchmark below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are curious how a native runtime schedules this kind of work onto OS threads, the Go side of it is covered in &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/programming/how-many-threads-go-really-uses-runtime-investigation-2026" rel="noopener noreferrer"&gt;How Many Threads Does Go Really Use?&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The watch mode was rebuilt on a native file watcher, and the language service got the same treatment. Microsoft’s reported numbers from the &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/" rel="noopener noreferrer"&gt;release post&lt;/a&gt; are worth knowing, with the caveat that they are Microsoft’s and from large codebases: “typically 8× to 12×” on full builds, VS Code’s time to the first error down from 17.5 s to under 1.3 s, Slack’s CI type-check from 7.5 to 1.25 minutes, and Canva’s time to the first error in the editor from 58 to 4.8 seconds.&lt;/p&gt;
&lt;h2&gt;
  
  
  The benchmark: three real projects, three compilers
&lt;/h2&gt;

&lt;p&gt;Release notes promise “10x faster”. I wanted to see it on code I did not write, so I took three popular open-source projects, type-checked each one with TypeScript 6.0.3, TypeScript 7.0.2 (default and &lt;code&gt;--singleThreaded&lt;/code&gt;) and the 7.1.0-beta from 20 October, and timed every run. The whole setup is open source, so you can repeat it on your own machine: &lt;a href="https://github.com/LF3551/typescript-7-vs-6-benchmark" rel="noopener noreferrer"&gt;typescript-7-vs-6-benchmark on GitHub&lt;/a&gt;. Every run was &lt;code&gt;tsc -p &amp;lt;tsconfig&amp;gt; --noEmit&lt;/code&gt;: type-checking only, no emit.&lt;/p&gt;

&lt;p&gt;The test subjects, starting with the stress test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;type-fest&lt;/strong&gt; , a collection of type utilities that spends nearly all of its time in type-level computation. Recursive conditional types all the way down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kysely&lt;/strong&gt; , a type-safe SQL query builder. A typical generics-heavy library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hono&lt;/strong&gt; , a web framework, checked twice: on the source only, and on source plus tests. The tests are where the two compilers start to disagree, as you will see below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each configuration ran several times in separate processes on Node.js 24. The first run was thrown away as a warm-up, and the table shows the median of the rest. All compilers got the same generous Node heap. The 7.1 beta pulled a handful more files into the program on type-fest, so read its column as a close comparison rather than an exact one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;TS 6.0.3&lt;/th&gt;
&lt;th&gt;TS 7.0.2 &lt;code&gt;--singleThreaded&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;TS 7.0.2 default&lt;/th&gt;
&lt;th&gt;TS 7.1.0-beta&lt;/th&gt;
&lt;th&gt;Speedup 7.0.2 vs 6.0.3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;type-fest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;23.9 s&lt;/td&gt;
&lt;td&gt;16.4 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.1 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.6 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.9×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;kysely&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.37 s&lt;/td&gt;
&lt;td&gt;0.48 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.27 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.22 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.1×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;hono&lt;/strong&gt; (src + tests)&lt;/td&gt;
&lt;td&gt;0.82 s&lt;/td&gt;
&lt;td&gt;0.34 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.16 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.15 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.0×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;hono&lt;/strong&gt; (src only)&lt;/td&gt;
&lt;td&gt;0.46 s&lt;/td&gt;
&lt;td&gt;0.17 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.10 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.10 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.4×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frioj0yozieljxvja7xw9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frioj0yozieljxvja7xw9.webp" alt="TypeScript 7 vs 6 benchmark on type-fest, kysely and hono: type-checks are 2.9× to 5.1× faster on TypeScript 7.0.2, and the single-threaded bars show how much comes from native code alone." width="799" height="513"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Native code alone is worth 1.5–2.9×. Four parallel checkers add another 1.6–2.1× on top.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What the numbers say, without rounding in my favour:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3× to 5×, not 10×.&lt;/strong&gt; That is lower than the headline 8–12×, and I would not stretch the table to fit. Microsoft’s figures are for full builds of large repositories, and the projects here are smaller. type-fest is the extreme case: it is a pile of recursive conditional types, so the time is dominated by the type-level work itself, and it still came out nearly three times faster. A fair summary is “between about 3× and 12×, depending on the codebase”, and the only reliable way to place yours is to measure it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native code alone is the bigger half.&lt;/strong&gt; &lt;code&gt;--singleThreaded&lt;/code&gt; was 1.5–2.9× faster than TypeScript 6. The four default checkers then roughly halved the time again (1.6–2.1×).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript 6 cannot even finish type-fest on a default Node heap.&lt;/strong&gt; It ran out of memory at about 4 GB of heap and needed &lt;code&gt;--max-old-space-size&lt;/code&gt; raised to complete, which is the JavaScript ceiling that a native binary does not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7.1 is faster again.&lt;/strong&gt; The beta cut type-fest from 8.1 s to 4.6 s, 5.1× faster than 6.0.3, and was 4.7–6.4× faster than TypeScript 6 on the others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Memory is a more mixed story. Peak resident memory, in megabytes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;TS 6.0.3&lt;/th&gt;
&lt;th&gt;TS 7.0.2 default&lt;/th&gt;
&lt;th&gt;TS 7.1.0-beta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;type-fest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6,196&lt;/td&gt;
&lt;td&gt;7,141&lt;/td&gt;
&lt;td&gt;5,838&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;kysely&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;460&lt;/td&gt;
&lt;td&gt;307&lt;/td&gt;
&lt;td&gt;222&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;hono&lt;/strong&gt; (src + tests)&lt;/td&gt;
&lt;td&gt;440&lt;/td&gt;
&lt;td&gt;207&lt;/td&gt;
&lt;td&gt;165&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;hono&lt;/strong&gt; (src only)&lt;/td&gt;
&lt;td&gt;327&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the small and medium projects TypeScript 7 used a third to two thirds less memory. On type-fest, with the most type-level work, four checkers used about 15% more than the single JavaScript thread, and the 7.1 beta used less than both. If your CI runners are memory-bound, &lt;code&gt;--checkers 2&lt;/code&gt; or &lt;code&gt;--singleThreaded&lt;/code&gt; is the first knob to try.&lt;/p&gt;

&lt;p&gt;Warning&lt;/p&gt;
&lt;p&gt;Speed is not the same as identical results. On hono’s test suite, &lt;code&gt;tsc 6.0.3&lt;/code&gt; reported &lt;strong&gt;0 errors&lt;/strong&gt; and &lt;code&gt;tsc 7.0.2&lt;/code&gt; reported &lt;strong&gt;4&lt;/strong&gt;: two unused &lt;code&gt;@ts-expect-error&lt;/code&gt; directives (TS2578) and two “No overload matches this call” errors (TS2769, &lt;code&gt;string&lt;/code&gt; not assignable to &lt;code&gt;string[]&lt;/code&gt;), all in one test file. The 7.1 beta reported 6, and on hono’s source-only configuration the beta added 2 &lt;code&gt;noImplicitAny&lt;/code&gt; errors that 6.0 and 7.0 did not report. Each of these may be a legitimate correction or a regression, and either way you want to find out in a branch, not in a release.&lt;/p&gt;
&lt;p&gt;Note&lt;/p&gt;
&lt;p&gt;Reproduce it in three commands: &lt;code&gt;git clone https://github.com/LF3551/typescript-7-vs-6-benchmark&lt;/code&gt;, then &lt;code&gt;npm run setup&lt;/code&gt; and &lt;code&gt;npm run bench&lt;/code&gt;. The &lt;a href="https://github.com/LF3551/typescript-7-vs-6-benchmark" rel="noopener noreferrer"&gt;repository&lt;/a&gt; pins the three projects at exact commits, installs TypeScript 6.0.3, 7.0.2 and 7.1.0-beta in separate folders so the binaries stay apart, and uses the same &lt;code&gt;NODE_OPTIONS=--max-old-space-size&lt;/code&gt; for every compiler. Add your own project in &lt;code&gt;config.json&lt;/code&gt;. Single-machine numbers are a sanity check, not a leaderboard; compare ratios, not seconds.&lt;/p&gt;
&lt;h2&gt;
  
  
  What TypeScript 6 changed for you
&lt;/h2&gt;

&lt;p&gt;Most of the upgrade work happens in 6.0, because that is where the defaults moved. If your &lt;code&gt;tsconfig.json&lt;/code&gt; is short and relies on defaults, these are the ones that bite:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default before 6.0&lt;/th&gt;
&lt;th&gt;Default in 6.0 and 7.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;module&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;commonjs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;esnext&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;es5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the latest stable ECMAScript version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;types&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;all of &lt;code&gt;@types/*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[]&lt;/code&gt; (nothing is included automatically)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rootDir&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;inferred from the input files&lt;/td&gt;
&lt;td&gt;the folder containing the &lt;code&gt;tsconfig.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;noUncheckedSideEffectImports&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;libReplacement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type ordering (&lt;code&gt;stableTypeOrdering&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;not deterministic&lt;/td&gt;
&lt;td&gt;opt-in flag in 6.0, always on in 7.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of these deserve attention. &lt;strong&gt;&lt;code&gt;types: []&lt;/code&gt;&lt;/strong&gt; means &lt;code&gt;process&lt;/code&gt;, &lt;code&gt;describe&lt;/code&gt; and every other global from &lt;code&gt;@types/node&lt;/code&gt; or &lt;code&gt;@types/jest&lt;/code&gt; disappears until you list them, which is the most common first error in an upgraded project. The upside is real: the 6.0 announcement notes that many projects gained 20–50% in build time simply by setting &lt;code&gt;types&lt;/code&gt; explicitly. And &lt;strong&gt;&lt;code&gt;stableTypeOrdering&lt;/code&gt;&lt;/strong&gt; makes the order in which the checker discovers types deterministic, which in 6.0 you can opt into with a flag (it costs up to 25% in check time, so it is for diagnosing your migration, not for everyday use). In 7.0 it is always on and cannot be turned off, so it is one of the places where results can differ from what you saw on 6.0.&lt;/p&gt;

&lt;p&gt;A tsconfig that works on both versions looks like this:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;tsconfig.json&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"es2022"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"esnext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bundler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"noEmit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;6.0 also added features that are worth knowing, all available on 7: &lt;code&gt;#/&lt;/code&gt; subpath imports, &lt;code&gt;bundler&lt;/code&gt; resolution together with &lt;code&gt;commonjs&lt;/code&gt;, the &lt;code&gt;es2025&lt;/code&gt; target and library, Temporal types, &lt;code&gt;Map.getOrInsert&lt;/code&gt; and &lt;code&gt;RegExp.escape&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks in TypeScript 7
&lt;/h2&gt;

&lt;p&gt;In 6.0, a set of long-standing options became deprecated and could be silenced with &lt;code&gt;"ignoreDeprecations": "6.0"&lt;/code&gt;. In 7.0 that switch is gone, and the options are &lt;strong&gt;hard errors&lt;/strong&gt; :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option or syntax&lt;/th&gt;
&lt;th&gt;Why it goes&lt;/th&gt;
&lt;th&gt;Do instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target: "es5"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Legacy output target&lt;/td&gt;
&lt;td&gt;Target &lt;code&gt;es2015&lt;/code&gt; or newer and let a bundler down-level if you really need old browsers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;downlevelIteration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only needed for targets below ES2015&lt;/td&gt;
&lt;td&gt;Remove it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;moduleResolution: "node10"&lt;/code&gt; (&lt;code&gt;node&lt;/code&gt;) and &lt;code&gt;"classic"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pre-ESM resolution&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bundler&lt;/code&gt;, &lt;code&gt;node16&lt;/code&gt; or &lt;code&gt;nodenext&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;module: "amd"&lt;/code&gt;, &lt;code&gt;"umd"&lt;/code&gt;, &lt;code&gt;"systemjs"&lt;/code&gt;, &lt;code&gt;"none"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Obsolete module formats&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;esnext&lt;/code&gt; or &lt;code&gt;preserve&lt;/code&gt; with a bundler, or &lt;code&gt;commonjs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resolves bare imports to the project root&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;paths&lt;/code&gt; with explicit mappings, or &lt;code&gt;#/&lt;/code&gt; subpath imports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;outFile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed in 6.0&lt;/td&gt;
&lt;td&gt;A bundler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;esModuleInterop: false&lt;/code&gt;, &lt;code&gt;allowSyntheticDefaultImports: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Interop is always on now&lt;/td&gt;
&lt;td&gt;Remove the setting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alwaysStrict: false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Strict mode is always on&lt;/td&gt;
&lt;td&gt;Remove the setting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;module Foo {}&lt;/code&gt; for namespaces&lt;/td&gt;
&lt;td&gt;Ambiguous keyword&lt;/td&gt;
&lt;td&gt;&lt;code&gt;namespace Foo {}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;import x from "y" assert { type: "json" }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replaced by &lt;code&gt;with&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;with { type: "json" }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsc file.ts&lt;/code&gt; while a &lt;code&gt;tsconfig.json&lt;/code&gt; exists&lt;/td&gt;
&lt;td&gt;Easy to forget the config is ignored&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tsc --ignoreConfig file.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most of these are one-line deletions. &lt;code&gt;baseUrl&lt;/code&gt; is the one that touches imports, and a small tool, &lt;code&gt;ts5to6&lt;/code&gt;, rewrites &lt;code&gt;baseUrl&lt;/code&gt; and &lt;code&gt;rootDir&lt;/code&gt; for you.&lt;/p&gt;

&lt;p&gt;Beyond configuration, a few behaviours changed deliberately: template-literal type inference now treats strings by code points, and JavaScript support lost some old JSDoc and Closure-style syntax (&lt;code&gt;@enum&lt;/code&gt;, &lt;code&gt;@class&lt;/code&gt;, postfix &lt;code&gt;!&lt;/code&gt;, Closure function types). Neither will touch most TypeScript code, but they are the places to look if a test fails after the switch with no config error.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fryvrg3hhvo772vquqyr8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fryvrg3hhvo772vquqyr8.webp" alt="TypeScript upgrade path from 5.9 through 6.0.3 to 7.0 and 7.1, with the options that become hard errors and the tools that can switch today." width="799" height="433"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Clear the deprecations on 6.0, then switch CI to the native compiler, and let the tools that need the compiler API wait for 7.1.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Tooling: what works with 7 today
&lt;/h2&gt;

&lt;p&gt;This is where “same language” meets reality. TypeScript 7.0 ships &lt;strong&gt;without a programmatic API&lt;/strong&gt; , because the compiler is no longer a JavaScript library you can &lt;code&gt;require&lt;/code&gt;. That has a clear consequence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Works on 7.0?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsc&lt;/code&gt; in the terminal and in CI&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editor type-checking for plain TS and TSX&lt;/td&gt;
&lt;td&gt;Yes, through the TypeScript 7 language server (a VS Code extension; Visual Studio enables it per workspace)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools that &lt;code&gt;import "typescript"&lt;/code&gt; (typescript-eslint, codemods, doc generators)&lt;/td&gt;
&lt;td&gt;Not directly; use the 6.0 alias&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language services for Vue, MDX, Astro, Svelte, Angular templates&lt;/td&gt;
&lt;td&gt;Not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--watch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes, rebuilt on a native watcher&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The workaround is to run both. The &lt;code&gt;@typescript/typescript6&lt;/code&gt; package ships the 6.0 compiler with a &lt;code&gt;tsc6&lt;/code&gt; binary, and npm aliases let anything that imports &lt;code&gt;typescript&lt;/code&gt; keep getting 6.0 while the native compiler lives under its own name:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;package.json&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"devDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"typescript"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:@typescript/typescript6@^6.0.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"@typescript/native"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:typescript@^7.0.2"&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;em&gt;Run both while you migrate&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
npm i &lt;span class="nt"&gt;-D&lt;/span&gt; typescript@7 @typescript/typescript6

npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt; &lt;span class="c"&gt;# native TypeScript 7&lt;/span&gt;

npx tsc6 &lt;span class="nt"&gt;--noEmit&lt;/span&gt; &lt;span class="c"&gt;# TypeScript 6.0 for comparison&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;This installs both command-line tools. Use the alias form above instead when tools such as typescript-eslint need &lt;code&gt;import "typescript"&lt;/code&gt; to resolve to 6.0. In VS Code, the “Disable TypeScript 7 Language Server” command switches the editor back to 6.0 if the language server ever gets in your way.&lt;/p&gt;

&lt;p&gt;Tip&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;tsc6&lt;/code&gt; in CI for a few weeks and diff the outputs. Any file where the two disagree is either a bug you want to know about now or a bug you want to report; the hono result above is exactly the kind of case this catches.&lt;/p&gt;

&lt;p&gt;The same “run old and new side by side” discipline is what makes other toolchain swaps painless. It is how I would approach &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/programming/biome-2026-replace-eslint-prettier-benchmark" rel="noopener noreferrer"&gt;replacing ESLint and Prettier with Biome&lt;/a&gt; and what made the &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/programming/jest-to-vitest-migration" rel="noopener noreferrer"&gt;Jest to Vitest migration&lt;/a&gt; predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A safe upgrade path
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade to TypeScript 6.0.3 and pin it.&lt;/strong&gt; Fix the errors that appear, mostly missing &lt;code&gt;types&lt;/code&gt; entries and &lt;code&gt;strict&lt;/code&gt; fallout, and commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear every deprecation warning.&lt;/strong&gt; Run &lt;code&gt;tsc&lt;/code&gt; and treat each deprecation as an error; use &lt;code&gt;ts5to6&lt;/code&gt; for &lt;code&gt;baseUrl&lt;/code&gt; and &lt;code&gt;rootDir&lt;/code&gt;. Do not reach for &lt;code&gt;ignoreDeprecations&lt;/code&gt; as the final answer, because it does nothing on 7.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;types&lt;/code&gt; and &lt;code&gt;rootDir&lt;/code&gt; explicitly.&lt;/strong&gt; It is the cheapest speedup in the whole migration and removes the two defaults that changed most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optionally run 6.0 with &lt;code&gt;--stableTypeOrdering&lt;/code&gt;.&lt;/strong&gt; It makes 6.0 diagnostics deterministic in the same way as 7.0, so any difference you see afterwards is from the port, not from ordering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add TypeScript 7 next to 6 in CI.&lt;/strong&gt; Run both &lt;code&gt;tsc&lt;/code&gt; and &lt;code&gt;tsc6&lt;/code&gt;, fail the build on &lt;code&gt;tsc6&lt;/code&gt;, and report &lt;code&gt;tsc&lt;/code&gt; as a non-blocking job. Read every disagreement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch the blocking check to 7.&lt;/strong&gt; Keep the 6.0 alias for editors and any tool that imports &lt;code&gt;typescript&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune the parallelism.&lt;/strong&gt; Start with the default four checkers; use &lt;code&gt;--checkers 2&lt;/code&gt; or &lt;code&gt;--singleThreaded&lt;/code&gt; on memory-limited runners; use &lt;code&gt;--builders&lt;/code&gt; on monorepos with project references. Pin the same &lt;code&gt;--checkers&lt;/code&gt; value on every machine and in CI, because the release notes warn that changing it can, in rare cases, surface order-dependent results.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you maintain a library that exposes the compiler API, or a project with Vue, Svelte, Astro or Angular templates, stop at step 5 for now. That is a decision, not a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TypeScript 7.1 adds
&lt;/h2&gt;

&lt;p&gt;TypeScript 7.1 reached &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-1-beta/" rel="noopener noreferrer"&gt;beta on 20 October 2026&lt;/a&gt;, installable as &lt;code&gt;typescript@beta&lt;/code&gt;, with a release candidate expected about a month later. It is the release that closes most of the gaps above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new compiler API&lt;/strong&gt; , with entry points such as &lt;code&gt;typescript/sync&lt;/code&gt;, &lt;code&gt;typescript/ast&lt;/code&gt; and &lt;code&gt;typescript/fs&lt;/code&gt;, snapshots, file-system layers and support for &lt;code&gt;using&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content mappers&lt;/strong&gt; , configured with &lt;code&gt;contentMappers&lt;/code&gt; in &lt;code&gt;tsconfig.json&lt;/code&gt;, for non-TypeScript files such as Vue, Astro, Svelte and MDX, with &lt;code&gt;tsc --runExternalCode&lt;/code&gt; to run them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import attributes in ambient modules&lt;/strong&gt; , &lt;code&gt;import source&lt;/code&gt; syntax, the &lt;code&gt;es2026&lt;/code&gt; target and new library methods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More optimizations&lt;/strong&gt; , visible in the benchmark above, and stricter checking of distributive type parameters, which can surface new errors in type-heavy code. I did not trace the extra hono diagnostics to a specific change, so treat their cause as unknown.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which version should you run?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain TypeScript or React app, CI type-check is slow&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;7.0 for CI&lt;/strong&gt; now, after a side-by-side run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large monorepo with project references&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;7.0&lt;/strong&gt; , with &lt;code&gt;--builders&lt;/code&gt; and &lt;code&gt;--checkers&lt;/code&gt; tuned to your runners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vue, Svelte, Astro, MDX or Angular templates&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6.0 for the editor&lt;/strong&gt; , 7.0 for CLI checks, wait for 7.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Library or tool that imports &lt;code&gt;typescript&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6.0&lt;/strong&gt; until the 7.1 API is stable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still on &lt;code&gt;target es5&lt;/code&gt;, &lt;code&gt;baseUrl&lt;/code&gt;, &lt;code&gt;node10&lt;/code&gt; resolution or AMD/UMD&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6.0 first&lt;/strong&gt; , clear the deprecations, then 7.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory-limited CI runners&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.0 with &lt;code&gt;--checkers 2&lt;/code&gt; or &lt;code&gt;--singleThreaded&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;TypeScript 7 is the same language with a new engine, and the engine is what you will notice: in this test, type-checks were 2.9–5.1× faster than on TypeScript 6, with a larger win on bigger codebases according to Microsoft’s own numbers, and the 7.1 beta is faster still. The price is a migration with edges. Every option 6.0 deprecated is now an error, the compiler API and the template-language services have to wait for 7.1, and on at least one popular project the two compilers disagree about the same code. So go through 6.0, run both compilers side by side, switch CI first and the rest of the toolchain when its support lands. If you do that, the upgrade takes an afternoon of configuration, and you get your build minutes back.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/programming/typescript-7-vs-typescript-6-native-compiler-upgrade-guide" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript7</category>
      <category>typescript6</category>
      <category>tsgo</category>
      <category>typescriptgo</category>
    </item>
    <item>
      <title>DuckDB Explained: When a Database on Your Laptop Beats SQLite, Polars and BigQuery</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/duckdb-explained-when-a-database-on-your-laptop-beats-sqlite-polars-and-bigquery-2nj0</link>
      <guid>https://dev.to/aleksei_aleinikov/duckdb-explained-when-a-database-on-your-laptop-beats-sqlite-polars-and-bigquery-2nj0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fgkbu2m9wy9w7ucah0s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fgkbu2m9wy9w7ucah0s.webp" alt="DuckDB Explained: When a Database on Your Laptop Beats SQLite, Polars and BigQuery" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a decade the rule was simple: small data goes in pandas, big data goes in a warehouse, and anything in between gets a Spark cluster nobody wants to maintain. &lt;strong&gt;DuckDB&lt;/strong&gt; quietly broke that rule. It is a full analytical SQL database that installs with &lt;code&gt;pip&lt;/code&gt;, runs inside your Python process, and reads a folder of Parquet files faster than most teams’ warehouse returns a dashboard tile.&lt;/p&gt;

&lt;p&gt;That makes it easy to overhype. DuckDB is not a BigQuery killer, and it is not a faster SQLite for your app. It is something more specific, and once you see where its edges are, it becomes one of the most useful tools in a data stack. This guide explains what DuckDB is, shows a benchmark you can rerun yourself, and draws the line between “use DuckDB” and “you still need the warehouse”.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fgkbu2m9wy9w7ucah0s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fgkbu2m9wy9w7ucah0s.webp" alt="DuckDB: the analytical database that runs inside your process." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What DuckDB actually is
&lt;/h2&gt;

&lt;p&gt;DuckDB is an &lt;strong&gt;in-process OLAP database&lt;/strong&gt;. Each of those words matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In-process.&lt;/strong&gt; There is no server. DuckDB is a library linked into your program: Python, R, Node.js, Java, Go, Rust, the CLI, even the browser through WebAssembly. A database is a single file, or just memory. Nothing to deploy, no port, no connection pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OLAP.&lt;/strong&gt; It is built for analytical queries: scan millions of rows, filter, join, group, aggregate. Data is stored &lt;strong&gt;by column&lt;/strong&gt; , so a query that touches five columns of a hundred reads only those five.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vectorised.&lt;/strong&gt; Instead of processing one row at a time, the engine works on batches of values per column, which keeps CPU caches busy and uses every core.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It came out of the database research group at CWI in Amsterdam, created by Mark Raasveldt and Hannes Mühleisen and first released in 2019. It is MIT-licensed and governed by the non-profit DuckDB Foundation. In August 2026 the project passed 40,000 GitHub stars, and the same month DuckLabs, the company behind its development, announced it would &lt;a href="https://duckdb.org/2026/08/26/ducklabs-to-join-aws.html" rel="noopener noreferrer"&gt;join AWS&lt;/a&gt; with an explicit promise that DuckDB, DuckLake and Quack stay MIT-licensed under the Foundation.&lt;/p&gt;

&lt;p&gt;The best one-line description is still the old one: &lt;strong&gt;DuckDB is SQLite for analytics.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3qdpoeuw4b4tbrwq4fa.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3qdpoeuw4b4tbrwq4fa.webp" alt="In-process vs client-server: a classic warehouse sits behind a network and a server, while DuckDB runs inside the application process and reads Parquet, CSV and S3 directly." width="799" height="473"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;No server and no network hop: the query engine lives in your process and reads files where they are.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Five minutes from zero to a query
&lt;/h2&gt;

&lt;p&gt;The fastest way to understand DuckDB is to point it at a file you already have.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Terminal window&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;duckdb

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;em&gt;taxi.py&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;# Query Parquet files directly: no CREATE TABLE, no load step.
&lt;/span&gt;
&lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;

    SELECT payment_type, count(*) AS trips, avg(total_amount) AS avg_total

    FROM &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;yellow_tripdata_2025-0*.parquet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;

    GROUP BY payment_type

    ORDER BY trips DESC

&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;A few things that surprise people the first time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Files are tables.&lt;/strong&gt; &lt;code&gt;FROM 'data/*.parquet'&lt;/code&gt;, &lt;code&gt;FROM 'export.csv'&lt;/code&gt; and &lt;code&gt;FROM 'events.json'&lt;/code&gt; all work, with globbing and automatic schema detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DataFrames are tables too.&lt;/strong&gt; If a pandas or Polars DataFrame called &lt;code&gt;df&lt;/code&gt; is in scope, &lt;code&gt;duckdb.sql("SELECT ... FROM df")&lt;/code&gt; queries it in place, and &lt;code&gt;.df()&lt;/code&gt; or &lt;code&gt;.pl()&lt;/code&gt; turns the result back into one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote storage is a path.&lt;/strong&gt; With the &lt;code&gt;httpfs&lt;/code&gt; extension, &lt;code&gt;FROM 's3://bucket/events/*.parquet'&lt;/code&gt; or a GCS or HTTPS URL works the same way, and DuckDB reads only the columns and row groups it needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It speaks lakehouse.&lt;/strong&gt; Extensions read and write Iceberg and Delta tables and attach PostgreSQL, MySQL and SQLite databases, so one SQL statement can join a Postgres table with Parquet on S3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you prefer a terminal, the &lt;code&gt;duckdb&lt;/code&gt; CLI does the same, and &lt;code&gt;duckdb -ui&lt;/code&gt; opens a local notebook-style web UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark: 11.2 million taxi trips on a laptop
&lt;/h2&gt;

&lt;p&gt;Claims like “100× faster” are cheap, so here is a test you can rerun. The data is the public NYC TLC yellow-taxi record for January to March 2025: three Parquet files, 189 MB, &lt;strong&gt;11,198,026 trips&lt;/strong&gt;. Three questions any analyst would ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Q1:&lt;/strong&gt; trips and average fare by payment type (one group-by).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Q2:&lt;/strong&gt; trips and average distance per hour of day, ignoring zero-distance trips (filter, timestamp function, group-by).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Q3:&lt;/strong&gt; top 10 pickup zones by revenue (join with the 265-row zone lookup, group-by, sort).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each engine ran in its own process on the same laptop. Every query ran four times; the table shows the median of the last three. All engines returned identical answers (7,484,734 card trips averaging $28.47; JFK Airport first with $31.4 million).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Load step&lt;/th&gt;
&lt;th&gt;Q1&lt;/th&gt;
&lt;th&gt;Q2&lt;/th&gt;
&lt;th&gt;Q3&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;DuckDB 1.5.6&lt;/strong&gt; (SQL on Parquet)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;237 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polars 1.36 (lazy, &lt;code&gt;scan_parquet&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;19 ms&lt;/td&gt;
&lt;td&gt;128 ms&lt;/td&gt;
&lt;td&gt;305 ms&lt;/td&gt;
&lt;td&gt;not measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polars 1.36 (eager, data in RAM)&lt;/td&gt;
&lt;td&gt;0.07 s&lt;/td&gt;
&lt;td&gt;10 ms&lt;/td&gt;
&lt;td&gt;127 ms&lt;/td&gt;
&lt;td&gt;351 ms&lt;/td&gt;
&lt;td&gt;3.3 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pandas 2.3 (data in RAM)&lt;/td&gt;
&lt;td&gt;1.5 s&lt;/td&gt;
&lt;td&gt;126 ms&lt;/td&gt;
&lt;td&gt;347 ms&lt;/td&gt;
&lt;td&gt;661 ms&lt;/td&gt;
&lt;td&gt;3.4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite 3.53 (table on disk)&lt;/td&gt;
&lt;td&gt;19.8 s&lt;/td&gt;
&lt;td&gt;1,800 ms&lt;/td&gt;
&lt;td&gt;3,584 ms&lt;/td&gt;
&lt;td&gt;4,529 ms&lt;/td&gt;
&lt;td&gt;52 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2eq5h9myrq6rewhvogq6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2eq5h9myrq6rewhvogq6.webp" alt="Benchmark on 11.2 million NYC taxi trips: DuckDB answers in 16–44 ms with 237 MB of memory, Polars in 10–351 ms, pandas in 126–661 ms with 3.4 GB, SQLite in 1.8–4.5 s." width="800" height="467"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same three questions, same answers. DuckDB is fastest on every query with a join or a timestamp, and the only fast engine that does not need the data in RAM.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What the numbers say, honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs SQLite: about 100× faster.&lt;/strong&gt; Not because SQLite is bad, but because it is a row store built for transactions. Every analytical query reads every column of every row. SQLite used little memory because it streamed from disk, and it needed a 20-second load into a 529 MB file first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs pandas: 8–15× faster, a fifteenth of the memory.&lt;/strong&gt; pandas first has to materialise all 11 million rows in RAM (1.5 s, 3.4 GB). DuckDB streams the Parquet files and never holds the whole dataset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs Polars: close on simple work, ahead on joins.&lt;/strong&gt; Polars tied on Q1 and is a superb DataFrame library. On Q2 and Q3 DuckDB was 3.5–7× faster in this setup. Your queries may differ; the honest takeaway is that both are in a different league from pandas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The memory column is the one to remember. A laptop with 16 GB cannot load 50 GB into pandas, but DuckDB will query it: it streams what fits and &lt;strong&gt;spills to disk&lt;/strong&gt; for what does not. In March 2026 the DuckDB team ran all 99 TPC-DS queries at scale factor 300 on the &lt;a href="https://duckdb.org/2026/03/11/big-data-on-the-cheapest-macbook.html" rel="noopener noreferrer"&gt;cheapest MacBook&lt;/a&gt;, with 8 GB of RAM, in 79 minutes.&lt;/p&gt;

&lt;p&gt;Note&lt;/p&gt;

&lt;p&gt;Reproduce it: download the three &lt;code&gt;yellow_tripdata_2025-0{1,2,3}.parquet&lt;/code&gt; files and &lt;code&gt;taxi_zone_lookup.csv&lt;/code&gt; from the NYC TLC site, then run the three queries above in each engine. The SQLite file was built with DuckDB’s own &lt;code&gt;sqlite&lt;/code&gt; extension (&lt;code&gt;ATTACH 'taxi.sqlite' (TYPE sqlite)&lt;/code&gt;). Single-run numbers on one machine are a sanity check, not a leaderboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  DuckDB vs SQLite, Polars, pandas, ClickHouse, Spark and BigQuery
&lt;/h2&gt;

&lt;p&gt;Most “DuckDB vs X” questions are really “which shape of problem is this”. Here is the whole family on one page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;DuckDB&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Polars&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;pandas&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ClickHouse&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Spark&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;BigQuery / Snowflake&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What it is&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Embedded analytical SQL DB&lt;/td&gt;
&lt;td&gt;Embedded transactional SQL DB&lt;/td&gt;
&lt;td&gt;DataFrame library (Rust)&lt;/td&gt;
&lt;td&gt;DataFrame library&lt;/td&gt;
&lt;td&gt;Columnar database server&lt;/td&gt;
&lt;td&gt;Distributed compute engine&lt;/td&gt;
&lt;td&gt;Managed cloud warehouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runs as&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Library in your process&lt;/td&gt;
&lt;td&gt;Library in your process&lt;/td&gt;
&lt;td&gt;Library in your process&lt;/td&gt;
&lt;td&gt;Library in your process&lt;/td&gt;
&lt;td&gt;Server or cluster&lt;/td&gt;
&lt;td&gt;Cluster&lt;/td&gt;
&lt;td&gt;Cloud service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best at&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQL analytics on files and DataFrames&lt;/td&gt;
&lt;td&gt;Many small reads and writes&lt;/td&gt;
&lt;td&gt;DataFrame pipelines&lt;/td&gt;
&lt;td&gt;Small data, rich ecosystem&lt;/td&gt;
&lt;td&gt;Real-time analytics, many users&lt;/td&gt;
&lt;td&gt;Terabyte-scale ETL&lt;/td&gt;
&lt;td&gt;Shared, governed analytics at any scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Weak at&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Many concurrent writers&lt;/td&gt;
&lt;td&gt;Scans and aggregates&lt;/td&gt;
&lt;td&gt;SQL-first teams&lt;/td&gt;
&lt;td&gt;Memory, speed at scale&lt;/td&gt;
&lt;td&gt;Embedding, ops overhead&lt;/td&gt;
&lt;td&gt;Small data (overhead)&lt;/td&gt;
&lt;td&gt;Latency, cost of frequent scans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data larger than RAM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes, spills to disk&lt;/td&gt;
&lt;td&gt;Yes (row-by-row)&lt;/td&gt;
&lt;td&gt;Yes, streaming engine&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pick when&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One person or job, MBs to hundreds of GBs&lt;/td&gt;
&lt;td&gt;An app’s own data&lt;/td&gt;
&lt;td&gt;You think in DataFrames&lt;/td&gt;
&lt;td&gt;Data fits in RAM easily&lt;/td&gt;
&lt;td&gt;Dashboards for hundreds of users&lt;/td&gt;
&lt;td&gt;Data does not fit on one big machine&lt;/td&gt;
&lt;td&gt;A company shares one source of truth&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three distinctions do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs SQLite&lt;/strong&gt; is OLAP vs OLTP. Keep SQLite (or PostgreSQL) for application state; reach for DuckDB to analyse it. DuckDB can even &lt;code&gt;ATTACH&lt;/code&gt; the SQLite file and query it in place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs Polars&lt;/strong&gt; is mostly SQL vs DataFrame API. Both are fast, both stream, and they interoperate through Arrow with zero copies. Use whichever your team reads more easily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs ClickHouse&lt;/strong&gt; is embedded vs server. ClickHouse is built to serve hundreds of concurrent dashboard users from a cluster; DuckDB is built to sit inside one program.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where DuckDB beats the warehouse, and where it does not
&lt;/h2&gt;

&lt;p&gt;The most useful way to frame DuckDB is not “instead of BigQuery” but &lt;strong&gt;“before and beside BigQuery”&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;DuckDB wins when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One person or one job&lt;/strong&gt; is asking the questions: an analyst in a notebook, a nightly batch job, a CI test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data is a file or an extract&lt;/strong&gt; : Parquet on S3 or GCS, a CSV export, a sample of the warehouse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency matters more than scale&lt;/strong&gt; : 40 ms in a notebook loop beats a two-second round trip to any cloud service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want tests for data code.&lt;/strong&gt; A dbt project or a SQL transformation can run against a DuckDB file in CI in seconds, with no cloud credentials. dbt v2 (September 2026) &lt;a href="https://duckdb.org/2026/09/22/dbt-fusion.html" rel="noopener noreferrer"&gt;ships the DuckDB adapter built in&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The warehouse still wins when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Many people and services&lt;/strong&gt; need the same data at the same time, with roles, audit logs, row-level security and sharing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data reaches many terabytes&lt;/strong&gt; , or queries routinely scan more than one large machine can read quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need managed operations&lt;/strong&gt; : backups, high availability, governance, and someone else on call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the cost argument is subtler than the hype suggests. The five columns used above add up to under half a gigabyte for 11.2 million rows. In BigQuery on-demand, at &lt;a href="https://cloud.google.com/bigquery/pricing" rel="noopener noreferrer"&gt;$6.25 per TiB scanned&lt;/a&gt; after the first free TiB each month, a query over all of them costs about a quarter of a cent. &lt;strong&gt;At this size DuckDB does not save money; it saves the round trip.&lt;/strong&gt; The money argument starts when dashboards and scheduled jobs rescan terabytes all day. That is where pulling a stable extract into Parquet and querying it with DuckDB can take a real line off the bill. If you are still choosing the warehouse itself, see &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/bigquery-vs-snowflake-2026-honest-comparison" rel="noopener noreferrer"&gt;BigQuery vs Snowflake: An Honest Comparison&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;DuckDB does not replace the warehouse. It replaces the cluster you were about to build for data that fits on one machine, and the hundred small queries you were sending to the cloud just to look at it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2sj9dxw427juli4p660r.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2sj9dxw427juli4p660r.webp" alt="Where DuckDB fits: pandas and Polars for data in RAM, DuckDB for one process from megabytes to hundreds of gigabytes, DuckDB with Quack, DuckLake or MotherDuck for shared access, and BigQuery, Snowflake or ClickHouse for many users at terabyte scale." width="799" height="493"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The real boundary is not data size but how many writers and readers share the data.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The real limit is concurrency, not size
&lt;/h2&gt;

&lt;p&gt;People expect DuckDB’s limit to be data volume. In practice it is &lt;strong&gt;who else is writing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In plain in-process mode, &lt;a href="https://duckdb.org/docs/current/connect/concurrency.html" rel="noopener noreferrer"&gt;one process can open a database file read-write&lt;/a&gt;, using as many threads as it likes, or many processes can open it read-only. Two services cannot both write to the same &lt;code&gt;.duckdb&lt;/code&gt; file. That is a deliberate trade-off: keeping state in one process’s memory is part of why it is fast.&lt;/p&gt;

&lt;p&gt;Two developments in 2026 move that line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quack, DuckDB’s own client-server protocol.&lt;/strong&gt; Released as a preview in May 2026, it lets any DuckDB process serve its databases over HTTP and any other DuckDB &lt;code&gt;ATTACH&lt;/code&gt; to it. In the team’s &lt;a href="https://duckdb.org/2026/05/12/quack-remote-protocol.html" rel="noopener noreferrer"&gt;benchmarks&lt;/a&gt;, Quack moved 60 million rows in 4.9 seconds where the PostgreSQL protocol took 158, and handled about 5,400 small write transactions per second on eight threads, ahead of PostgreSQL on the same machines. Quack becomes stable with DuckDB 2.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckLake, a lakehouse format built on SQL.&lt;/strong&gt; Data lives as Parquet in object storage; the catalog (which files make up which table version) lives in a real database such as PostgreSQL. Many DuckDB instances can then read and write the same tables concurrently. DuckLake 1.0 was declared production-ready in April 2026. It solves the same problem as Iceberg and Delta, with a simpler catalog; the trade-offs between those two are in &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/iceberg-vs-delta-lake-2026" rel="noopener noreferrer"&gt;Iceberg vs Delta Lake: A Plain Guide&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you would rather not run any of that, &lt;strong&gt;MotherDuck&lt;/strong&gt; is the managed option: DuckDB in the cloud with storage, per-user compute, sharing and access control. Its &lt;a href="https://motherduck.com/product/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; has a free Lite plan (10 GB storage, 10 hours of compute a month) and a Business plan at $250 per organisation per month plus usage, with EU regions in Frankfurt and Dublin, which matters for GDPR-minded teams in Germany.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is new in DuckDB 2.0
&lt;/h2&gt;

&lt;p&gt;DuckDB 2.0, codenamed “Cyanoptera”, is on the &lt;a href="https://duckdb.org/release_calendar.html" rel="noopener noreferrer"&gt;release calendar&lt;/a&gt; for 21 October 2026, with a 2.0.1 bugfix release planned for November. The &lt;a href="https://duckdb.org/2026/08/17/duckdb-20-highlights.html" rel="noopener noreferrer"&gt;preview&lt;/a&gt; lists more than 10,000 commits since 1.5. The highlights that change how you use it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB as a server.&lt;/strong&gt; Quack graduates to stable, and a new &lt;code&gt;CONNECT&lt;/code&gt; statement points your session at a remote DuckDB, or at PostgreSQL or MySQL, and pushes the SQL down to run there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VARIANT&lt;/code&gt; as a first-class type.&lt;/strong&gt; Semi-structured data (think JSON logs) is shredded into columns automatically, so it compresses and queries like typed data without declaring a schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triggers&lt;/strong&gt; , &lt;code&gt;BEFORE&lt;/code&gt; and &lt;code&gt;AFTER&lt;/code&gt;, per row or per statement: audit tables without application code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asynchronous I/O&lt;/strong&gt; throughout the engine, with the biggest gains on S3 and other network storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NEAREST&lt;/code&gt; joins&lt;/strong&gt; for top-k similarity search over embeddings, useful if you are weighing whether you &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/do-you-need-a-vector-database-postgres-vs-dedicated-2026" rel="noopener noreferrer"&gt;need a dedicated vector database&lt;/a&gt; at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new storage format and a new PEG-based SQL parser&lt;/strong&gt; , plus a small set of breaking changes. Files written by 2.0 use the new format by default, so pin versions before upgrading shared files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need stability over features, the 1.4 line is the long-term-support release, supported until November 2026; from 1.4 on, every other minor version is LTS.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical starting point
&lt;/h2&gt;

&lt;p&gt;You do not need a migration plan to get value from DuckDB. Start small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Replace the slowest pandas step&lt;/strong&gt; in a notebook with &lt;code&gt;duckdb.sql(...)&lt;/code&gt; over the same DataFrame or Parquet file, and keep the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export a stable warehouse table to Parquet&lt;/strong&gt; once a day and point exploratory work at the file instead of the warehouse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run your SQL transformations against DuckDB in CI&lt;/strong&gt; , so a broken model fails in a pull request instead of in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only then&lt;/strong&gt; decide whether you need shared access through MotherDuck, Quack or DuckLake, or whether the warehouse should simply stay the source of truth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep the model in mind: &lt;strong&gt;one process, columnar, vectorised, reads files where they are.&lt;/strong&gt; Everything DuckDB is great at follows from it, and so does everything it is not built for. If you want to place it among the broader database families first, start with &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/sql-vs-nosql-2026-database-types-acid-base-how-to-choose" rel="noopener noreferrer"&gt;SQL vs NoSQL: database types and how to choose&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;DuckDB earns its hype on a narrow, very common problem: one person or one job asking analytical questions of data that fits on one machine. There it is faster than pandas, far faster than SQLite, at least as fast as Polars on most work, and it needs no infrastructure at all. It is not a shared warehouse, and pretending otherwise ends in file-lock errors. Use it before and beside BigQuery or Snowflake, not instead of them, and watch the concurrency story: with Quack and DuckLake, the line between “laptop database” and “real database” is moving faster than any other part of the data stack.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/duckdb-explained-vs-sqlite-polars-bigquery" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>duckdb</category>
      <category>duckdbvssqlite</category>
      <category>polars</category>
      <category>parquet</category>
    </item>
    <item>
      <title>DuckDB erklärt: Wann eine Datenbank auf dem Laptop SQLite, Polars und BigQuery schlägt</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/duckdb-erklart-wann-eine-datenbank-auf-dem-laptop-sqlite-polars-und-bigquery-schlagt-1ekd</link>
      <guid>https://dev.to/aleksei_aleinikov/duckdb-erklart-wann-eine-datenbank-auf-dem-laptop-sqlite-polars-und-bigquery-schlagt-1ekd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fgkbu2m9wy9w7ucah0s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fgkbu2m9wy9w7ucah0s.webp" alt="DuckDB erklärt: Wann eine Datenbank auf dem Laptop SQLite, Polars und BigQuery schlägt" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ein Jahrzehnt lang galt eine einfache Regel: Kleine Daten kommen in pandas, große in ein Data Warehouse, und alles dazwischen bekommt einen Spark-Cluster, den niemand pflegen will. &lt;strong&gt;DuckDB&lt;/strong&gt; hat diese Regel leise gebrochen. Es ist eine vollwertige analytische SQL-Datenbank, die sich per &lt;code&gt;pip&lt;/code&gt; installiert, in deinem Python-Prozess läuft und einen Ordner voller Parquet-Dateien schneller abfragt, als das Warehouse der meisten Teams eine Dashboard-Kachel liefert.&lt;/p&gt;

&lt;p&gt;Genau deshalb wird DuckDB leicht überschätzt. Es ist kein BigQuery-Killer und auch kein schnelleres SQLite für deine App. Es ist etwas Spezifischeres, und sobald du seine Grenzen kennst, wird es zu einem der nützlichsten Werkzeuge im Datenstack. Dieser Leitfaden erklärt, was DuckDB ist, zeigt einen Benchmark, den du selbst wiederholen kannst, und zieht die Linie zwischen „nimm DuckDB“ und „du brauchst weiterhin das Warehouse“.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fgkbu2m9wy9w7ucah0s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fgkbu2m9wy9w7ucah0s.webp" alt="DuckDB: die analytische Datenbank, die in deinem Prozess läuft." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Was DuckDB eigentlich ist
&lt;/h2&gt;

&lt;p&gt;DuckDB ist eine &lt;strong&gt;In-Process-OLAP-Datenbank&lt;/strong&gt;. Jedes dieser Wörter zählt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In-Process.&lt;/strong&gt; Es gibt keinen Server. DuckDB ist eine Bibliothek, die in dein Programm eingebunden wird: Python, R, Node.js, Java, Go, Rust, die CLI, per WebAssembly sogar der Browser. Eine Datenbank ist eine einzige Datei oder liegt nur im Speicher. Nichts zu deployen, kein Port, kein Connection-Pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OLAP.&lt;/strong&gt; DuckDB ist für analytische Abfragen gebaut: Millionen Zeilen scannen, filtern, joinen, gruppieren, aggregieren. Daten werden &lt;strong&gt;spaltenweise&lt;/strong&gt; gespeichert. Eine Abfrage, die fünf von hundert Spalten braucht, liest nur diese fünf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vektorisiert.&lt;/strong&gt; Statt Zeile für Zeile arbeitet die Engine auf Blöcken von Werten pro Spalte. Das hält die CPU-Caches gefüllt und nutzt alle Kerne.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DuckDB stammt aus der Datenbankforschung am CWI in Amsterdam, entwickelt von Mark Raasveldt und Hannes Mühleisen, erstmals 2019 veröffentlicht. Es steht unter der MIT-Lizenz und wird von der gemeinnützigen DuckDB Foundation verwaltet. Im August 2026 überschritt das Projekt 40.000 GitHub-Sterne, und im selben Monat kündigte DuckLabs, die Firma hinter der Entwicklung, an, &lt;a href="https://duckdb.org/2026/08/26/ducklabs-to-join-aws.html" rel="noopener noreferrer"&gt;zu AWS zu wechseln&lt;/a&gt;, mit dem ausdrücklichen Versprechen, dass DuckDB, DuckLake und Quack unter der Foundation MIT-lizenziert bleiben.&lt;/p&gt;

&lt;p&gt;Die beste Kurzbeschreibung ist immer noch die alte: &lt;strong&gt;DuckDB ist SQLite für Analysen.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3qdpoeuw4b4tbrwq4fa.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3qdpoeuw4b4tbrwq4fa.webp" alt="In-Process vs. Client-Server: Ein klassisches Warehouse sitzt hinter Netzwerk und Server, DuckDB läuft im Anwendungsprozess und liest Parquet, CSV und S3 direkt." width="799" height="473"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Kein Server, kein Netzwerk-Hop: Die Query-Engine lebt in deinem Prozess und liest Dateien dort, wo sie liegen.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  In fünf Minuten zur ersten Abfrage
&lt;/h2&gt;

&lt;p&gt;Am schnellsten verstehst du DuckDB, wenn du es auf eine Datei richtest, die du ohnehin hast.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Terminal window&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;duckdb

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;em&gt;taxi.py&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;# Parquet-Dateien direkt abfragen: kein CREATE TABLE, kein Ladeschritt.
&lt;/span&gt;
&lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;

    SELECT payment_type, count(*) AS trips, avg(total_amount) AS avg_total

    FROM &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;yellow_tripdata_2025-0*.parquet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;

    GROUP BY payment_type

    ORDER BY trips DESC

&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Was beim ersten Mal überrascht:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dateien sind Tabellen.&lt;/strong&gt; &lt;code&gt;FROM 'data/*.parquet'&lt;/code&gt;, &lt;code&gt;FROM 'export.csv'&lt;/code&gt; und &lt;code&gt;FROM 'events.json'&lt;/code&gt; funktionieren, mit Globbing und automatischer Schema-Erkennung.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DataFrames sind auch Tabellen.&lt;/strong&gt; Liegt ein pandas- oder Polars-DataFrame namens &lt;code&gt;df&lt;/code&gt; im Scope, fragt &lt;code&gt;duckdb.sql("SELECT ... FROM df")&lt;/code&gt; ihn direkt ab, und &lt;code&gt;.df()&lt;/code&gt; oder &lt;code&gt;.pl()&lt;/code&gt; macht aus dem Ergebnis wieder einen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote-Speicher ist nur ein Pfad.&lt;/strong&gt; Mit der Erweiterung &lt;code&gt;httpfs&lt;/code&gt; funktioniert &lt;code&gt;FROM 's3://bucket/events/*.parquet'&lt;/code&gt; oder eine GCS- bzw. HTTPS-URL genauso, und DuckDB liest nur die Spalten und Row-Groups, die es braucht.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Es spricht Lakehouse.&lt;/strong&gt; Erweiterungen lesen und schreiben Iceberg- und Delta-Tabellen und binden PostgreSQL-, MySQL- und SQLite-Datenbanken ein. Eine einzige SQL-Abfrage kann so eine Postgres-Tabelle mit Parquet auf S3 joinen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Im Terminal macht die &lt;code&gt;duckdb&lt;/code&gt;-CLI dasselbe, und &lt;code&gt;duckdb -ui&lt;/code&gt; öffnet eine lokale Web-Oberfläche im Notebook-Stil.&lt;/p&gt;

&lt;h2&gt;
  
  
  Der Benchmark: 11,2 Millionen Taxifahrten auf dem Laptop
&lt;/h2&gt;

&lt;p&gt;Aussagen wie „100-mal schneller“ sind billig, deshalb hier ein Test zum Nachmachen. Die Daten sind die öffentlichen Yellow-Taxi-Fahrten der New Yorker TLC von Januar bis März 2025: drei Parquet-Dateien, 189 MB, &lt;strong&gt;11.198.026 Fahrten&lt;/strong&gt;. Drei Fragen, die jede Analystin stellen würde:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Q1:&lt;/strong&gt; Fahrten und durchschnittlicher Betrag pro Zahlungsart (ein Group-by).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Q2:&lt;/strong&gt; Fahrten und durchschnittliche Strecke pro Tagesstunde, ohne Fahrten mit Strecke null (Filter, Zeitstempel-Funktion, Group-by).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Q3:&lt;/strong&gt; Die 10 umsatzstärksten Abholzonen (Join mit der Zonentabelle mit 265 Zeilen, Group-by, Sortierung).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Jede Engine lief in einem eigenen Prozess auf demselben Laptop. Jede Abfrage lief viermal; die Tabelle zeigt den Median der letzten drei Läufe. Alle Engines lieferten identische Ergebnisse (7.484.734 Kartenzahlungen mit durchschnittlich 28,47 $; JFK Airport vorn mit 31,4 Millionen $).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Ladeschritt&lt;/th&gt;
&lt;th&gt;Q1&lt;/th&gt;
&lt;th&gt;Q2&lt;/th&gt;
&lt;th&gt;Q3&lt;/th&gt;
&lt;th&gt;Speicher (Spitze)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;DuckDB 1.5.6&lt;/strong&gt; (SQL auf Parquet)&lt;/td&gt;
&lt;td&gt;keiner&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;237 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polars 1.36 (lazy, &lt;code&gt;scan_parquet&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;keiner&lt;/td&gt;
&lt;td&gt;19 ms&lt;/td&gt;
&lt;td&gt;128 ms&lt;/td&gt;
&lt;td&gt;305 ms&lt;/td&gt;
&lt;td&gt;nicht gemessen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polars 1.36 (eager, Daten im RAM)&lt;/td&gt;
&lt;td&gt;0,07 s&lt;/td&gt;
&lt;td&gt;10 ms&lt;/td&gt;
&lt;td&gt;127 ms&lt;/td&gt;
&lt;td&gt;351 ms&lt;/td&gt;
&lt;td&gt;3,3 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pandas 2.3 (Daten im RAM)&lt;/td&gt;
&lt;td&gt;1,5 s&lt;/td&gt;
&lt;td&gt;126 ms&lt;/td&gt;
&lt;td&gt;347 ms&lt;/td&gt;
&lt;td&gt;661 ms&lt;/td&gt;
&lt;td&gt;3,4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite 3.53 (Tabelle auf Disk)&lt;/td&gt;
&lt;td&gt;19,8 s&lt;/td&gt;
&lt;td&gt;1.800 ms&lt;/td&gt;
&lt;td&gt;3.584 ms&lt;/td&gt;
&lt;td&gt;4.529 ms&lt;/td&gt;
&lt;td&gt;52 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2eq5h9myrq6rewhvogq6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2eq5h9myrq6rewhvogq6.webp" alt="Benchmark mit 11,2 Millionen New Yorker Taxifahrten: DuckDB antwortet in 16–44 ms mit 237 MB Speicher, Polars in 10–351 ms, pandas in 126–661 ms mit 3,4 GB, SQLite in 1,8–4,5 s." width="800" height="467"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Dieselben drei Fragen, dieselben Antworten. DuckDB ist bei jeder Abfrage mit Join oder Zeitstempel am schnellsten und die einzige schnelle Engine, die die Daten nicht im RAM braucht.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Was die Zahlen ehrlich sagen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs. SQLite: etwa 100-mal schneller.&lt;/strong&gt; Nicht weil SQLite schlecht ist, sondern weil es ein Zeilenspeicher für Transaktionen ist. Jede analytische Abfrage liest jede Spalte jeder Zeile. SQLite brauchte wenig Speicher, weil es von der Platte streamte, musste die Daten aber erst 20 Sekunden lang in eine 529-MB-Datei laden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs. pandas: 8- bis 15-mal schneller, ein Fünfzehntel des Speichers.&lt;/strong&gt; pandas muss erst alle 11 Millionen Zeilen in den RAM holen (1,5 s, 3,4 GB). DuckDB streamt die Parquet-Dateien und hält nie den ganzen Datensatz.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs. Polars: gleichauf bei Einfachem, vorn bei Joins.&lt;/strong&gt; Polars lag bei Q1 gleichauf und ist eine hervorragende DataFrame-Bibliothek. Bei Q2 und Q3 war DuckDB in diesem Setup 3,5- bis 7-mal schneller. Deine Abfragen können anders aussehen; die ehrliche Erkenntnis ist, dass beide in einer anderen Liga spielen als pandas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Die Speicherspalte ist die wichtigste. Ein Laptop mit 16 GB kann keine 50 GB in pandas laden, aber DuckDB fragt sie trotzdem ab: Es streamt, was passt, und &lt;strong&gt;lagert auf die Platte aus&lt;/strong&gt; , was nicht passt. Im März 2026 ließ das DuckDB-Team alle 99 TPC-DS-Abfragen mit Skalierungsfaktor 300 auf dem &lt;a href="https://duckdb.org/2026/03/11/big-data-on-the-cheapest-macbook.html" rel="noopener noreferrer"&gt;günstigsten MacBook&lt;/a&gt; mit 8 GB RAM laufen: fertig in 79 Minuten.&lt;/p&gt;

&lt;p&gt;Hinweis&lt;/p&gt;

&lt;p&gt;Zum Nachmachen: Lade die drei Dateien &lt;code&gt;yellow_tripdata_2025-0{1,2,3}.parquet&lt;/code&gt; und &lt;code&gt;taxi_zone_lookup.csv&lt;/code&gt; von der NYC-TLC-Website und führe die drei Abfragen oben in jeder Engine aus. Die SQLite-Datei wurde mit DuckDBs eigener &lt;code&gt;sqlite&lt;/code&gt;-Erweiterung erzeugt (&lt;code&gt;ATTACH 'taxi.sqlite' (TYPE sqlite)&lt;/code&gt;). Einzelmessungen auf einer Maschine sind ein Plausibilitätscheck, keine Rangliste.&lt;/p&gt;

&lt;h2&gt;
  
  
  DuckDB vs. SQLite, Polars, pandas, ClickHouse, Spark und BigQuery
&lt;/h2&gt;

&lt;p&gt;Die meisten „DuckDB vs. X“-Fragen sind eigentlich die Frage: Welche Art von Problem liegt vor? Hier die ganze Familie auf einer Seite:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;DuckDB&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Polars&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;pandas&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ClickHouse&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Spark&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;BigQuery / Snowflake&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Was es ist&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Eingebettete Analyse-SQL-DB&lt;/td&gt;
&lt;td&gt;Eingebettete Transaktions-SQL-DB&lt;/td&gt;
&lt;td&gt;DataFrame-Bibliothek (Rust)&lt;/td&gt;
&lt;td&gt;DataFrame-Bibliothek&lt;/td&gt;
&lt;td&gt;Spaltenorientierter DB-Server&lt;/td&gt;
&lt;td&gt;Verteilte Compute-Engine&lt;/td&gt;
&lt;td&gt;Verwaltetes Cloud-Warehouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Läuft als&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bibliothek im Prozess&lt;/td&gt;
&lt;td&gt;Bibliothek im Prozess&lt;/td&gt;
&lt;td&gt;Bibliothek im Prozess&lt;/td&gt;
&lt;td&gt;Bibliothek im Prozess&lt;/td&gt;
&lt;td&gt;Server oder Cluster&lt;/td&gt;
&lt;td&gt;Cluster&lt;/td&gt;
&lt;td&gt;Cloud-Dienst&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stark bei&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQL-Analysen auf Dateien und DataFrames&lt;/td&gt;
&lt;td&gt;Vielen kleinen Lese- und Schreibzugriffen&lt;/td&gt;
&lt;td&gt;DataFrame-Pipelines&lt;/td&gt;
&lt;td&gt;Kleinen Daten, großem Ökosystem&lt;/td&gt;
&lt;td&gt;Echtzeit-Analysen, vielen Nutzern&lt;/td&gt;
&lt;td&gt;ETL im Terabyte-Bereich&lt;/td&gt;
&lt;td&gt;Gemeinsamen, kontrollierten Analysen in jeder Größe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schwach bei&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vielen gleichzeitigen Schreibern&lt;/td&gt;
&lt;td&gt;Scans und Aggregationen&lt;/td&gt;
&lt;td&gt;SQL-orientierten Teams&lt;/td&gt;
&lt;td&gt;Speicher, Tempo bei Größe&lt;/td&gt;
&lt;td&gt;Einbettung, Betriebsaufwand&lt;/td&gt;
&lt;td&gt;Kleinen Daten (Overhead)&lt;/td&gt;
&lt;td&gt;Latenz, Kosten häufiger Scans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Daten größer als RAM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ja, lagert auf Disk aus&lt;/td&gt;
&lt;td&gt;Ja (zeilenweise)&lt;/td&gt;
&lt;td&gt;Ja, Streaming-Engine&lt;/td&gt;
&lt;td&gt;Nein&lt;/td&gt;
&lt;td&gt;Ja&lt;/td&gt;
&lt;td&gt;Ja&lt;/td&gt;
&lt;td&gt;Ja&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wählen, wenn&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Eine Person oder ein Job, MB bis Hunderte GB&lt;/td&gt;
&lt;td&gt;Die eigenen Daten einer App&lt;/td&gt;
&lt;td&gt;Du in DataFrames denkst&lt;/td&gt;
&lt;td&gt;Daten locker in den RAM passen&lt;/td&gt;
&lt;td&gt;Dashboards für Hunderte Nutzer&lt;/td&gt;
&lt;td&gt;Daten nicht auf eine große Maschine passen&lt;/td&gt;
&lt;td&gt;Eine Firma eine gemeinsame Wahrheit teilt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Drei Unterscheidungen erledigen den Großteil:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs. SQLite&lt;/strong&gt; ist OLAP vs. OLTP. Behalte SQLite (oder PostgreSQL) für den Zustand deiner Anwendung, nimm DuckDB, um ihn zu analysieren. DuckDB kann die SQLite-Datei sogar per &lt;code&gt;ATTACH&lt;/code&gt; einbinden und direkt abfragen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs. Polars&lt;/strong&gt; ist vor allem SQL vs. DataFrame-API. Beide sind schnell, beide streamen, und sie tauschen Daten über Arrow ohne Kopien aus. Nimm das, was dein Team leichter liest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB vs. ClickHouse&lt;/strong&gt; ist eingebettet vs. Server. ClickHouse bedient aus einem Cluster Hunderte gleichzeitige Dashboard-Nutzer; DuckDB sitzt in einem einzigen Programm.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wo DuckDB das Warehouse schlägt und wo nicht
&lt;/h2&gt;

&lt;p&gt;Der nützlichste Rahmen für DuckDB ist nicht „statt BigQuery“, sondern &lt;strong&gt;„vor und neben BigQuery“&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;DuckDB gewinnt, wenn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;eine Person oder ein Job&lt;/strong&gt; die Fragen stellt: eine Analystin im Notebook, ein nächtlicher Batch-Job, ein CI-Test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;die Daten eine Datei oder ein Extrakt sind&lt;/strong&gt; : Parquet auf S3 oder GCS, ein CSV-Export, ein Ausschnitt des Warehouse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latenz wichtiger ist als Skalierung&lt;/strong&gt; : 40 ms in einer Notebook-Schleife schlagen jeden Zwei-Sekunden-Roundtrip zu einem Cloud-Dienst.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;du Tests für Datencode willst.&lt;/strong&gt; Ein dbt-Projekt oder eine SQL-Transformation läuft in der CI in Sekunden gegen eine DuckDB-Datei, ohne Cloud-Zugangsdaten. dbt v2 (September 2026) &lt;a href="https://duckdb.org/2026/09/22/dbt-fusion.html" rel="noopener noreferrer"&gt;bringt den DuckDB-Adapter direkt mit&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Das Warehouse gewinnt weiterhin, wenn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;viele Menschen und Dienste&lt;/strong&gt; gleichzeitig dieselben Daten brauchen, mit Rollen, Audit-Logs, Row-Level-Security und Freigaben.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;die Daten viele Terabyte erreichen&lt;/strong&gt; oder Abfragen regelmäßig mehr scannen, als eine große Maschine schnell lesen kann.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;du verwalteten Betrieb brauchst&lt;/strong&gt; : Backups, Hochverfügbarkeit, Governance und jemand anderes in Rufbereitschaft.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Und das Kostenargument ist subtiler, als der Hype nahelegt. Die fünf oben genutzten Spalten ergeben für 11,2 Millionen Zeilen weniger als ein halbes Gigabyte. In BigQuery On-Demand kostet eine Abfrage über alle fünf bei &lt;a href="https://cloud.google.com/bigquery/pricing" rel="noopener noreferrer"&gt;6,25 $ pro gescanntem TiB&lt;/a&gt;, nach dem kostenlosen ersten TiB pro Monat, etwa einen Viertelcent. &lt;strong&gt;Bei dieser Größe spart DuckDB kein Geld, sondern den Roundtrip.&lt;/strong&gt; Das Kostenargument beginnt, wenn Dashboards und geplante Jobs den ganzen Tag Terabytes neu scannen. Dann kann ein stabiler Extrakt als Parquet, abgefragt mit DuckDB, eine echte Zeile von der Rechnung streichen. Wenn du noch beim Warehouse selbst schwankst, lies &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/bigquery-vs-snowflake-2026-ehrlicher-vergleich" rel="noopener noreferrer"&gt;BigQuery vs. Snowflake: Ein ehrlicher Vergleich&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;DuckDB ersetzt nicht das Warehouse. Es ersetzt den Cluster, den du gerade für Daten bauen wolltest, die auf eine Maschine passen, und die hundert kleinen Abfragen, die du nur zum Anschauen in die Cloud geschickt hast.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2sj9dxw427juli4p660r.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2sj9dxw427juli4p660r.webp" alt="Wo DuckDB passt: pandas und Polars für Daten im RAM, DuckDB für einen Prozess von Megabytes bis Hunderte Gigabyte, DuckDB mit Quack, DuckLake oder MotherDuck für gemeinsamen Zugriff, und BigQuery, Snowflake oder ClickHouse für viele Nutzer im Terabyte-Bereich." width="799" height="493"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Die echte Grenze ist nicht die Datenmenge, sondern wie viele Schreiber und Leser die Daten teilen.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Die echte Grenze ist Nebenläufigkeit, nicht Größe
&lt;/h2&gt;

&lt;p&gt;Die meisten erwarten, dass DuckDB an der Datenmenge scheitert. In der Praxis ist die Grenze: &lt;strong&gt;Wer schreibt sonst noch?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Im reinen In-Process-Modus kann &lt;a href="https://duckdb.org/docs/current/connect/concurrency.html" rel="noopener noreferrer"&gt;ein Prozess eine Datenbankdatei lesend und schreibend öffnen&lt;/a&gt;, mit beliebig vielen Threads, oder viele Prozesse öffnen sie nur lesend. Zwei Dienste können nicht gleichzeitig in dieselbe &lt;code&gt;.duckdb&lt;/code&gt;-Datei schreiben. Das ist ein bewusster Kompromiss: Den Zustand im Speicher eines Prozesses zu halten, ist ein Grund für das Tempo.&lt;/p&gt;

&lt;p&gt;Zwei Entwicklungen aus 2026 verschieben diese Grenze:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quack, DuckDBs eigenes Client-Server-Protokoll.&lt;/strong&gt; Seit Mai 2026 als Vorschau verfügbar, lässt es jeden DuckDB-Prozess seine Datenbanken per HTTP bereitstellen, und jede andere DuckDB bindet sie per &lt;code&gt;ATTACH&lt;/code&gt; ein. In den &lt;a href="https://duckdb.org/2026/05/12/quack-remote-protocol.html" rel="noopener noreferrer"&gt;Benchmarks des Teams&lt;/a&gt; übertrug Quack 60 Millionen Zeilen in 4,9 Sekunden, das PostgreSQL-Protokoll brauchte 158, und schaffte mit acht Threads rund 5.400 kleine Schreibtransaktionen pro Sekunde, mehr als PostgreSQL auf denselben Maschinen. Mit DuckDB 2.0 wird Quack stabil.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckLake, ein Lakehouse-Format auf SQL-Basis.&lt;/strong&gt; Die Daten liegen als Parquet im Objektspeicher; der Katalog (welche Dateien welche Tabellenversion bilden) liegt in einer echten Datenbank wie PostgreSQL. Viele DuckDB-Instanzen können dann gleichzeitig dieselben Tabellen lesen und schreiben. DuckLake 1.0 wurde im April 2026 für produktionsreif erklärt. Es löst dasselbe Problem wie Iceberg und Delta, mit einem einfacheren Katalog; die Abwägung zwischen diesen beiden steht in &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/iceberg-vs-delta-lake-2026-vergleich" rel="noopener noreferrer"&gt;Iceberg vs. Delta Lake: Ein verständlicher Leitfaden&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wer nichts davon selbst betreiben will, nimmt &lt;strong&gt;MotherDuck&lt;/strong&gt; : DuckDB in der Cloud mit Speicher, Rechenleistung pro Nutzer, Freigaben und Zugriffskontrolle. Die &lt;a href="https://motherduck.com/product/pricing/" rel="noopener noreferrer"&gt;Preise&lt;/a&gt; umfassen einen kostenlosen Lite-Plan (10 GB Speicher, 10 Stunden Rechenzeit pro Monat) und einen Business-Plan für 250 $ pro Organisation und Monat plus Nutzung. Die EU-Regionen Frankfurt und Dublin sind für DSGVO-bewusste Teams in Deutschland ein wichtiger Punkt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was neu ist in DuckDB 2.0
&lt;/h2&gt;

&lt;p&gt;DuckDB 2.0 mit dem Codenamen „Cyanoptera“ steht im &lt;a href="https://duckdb.org/release_calendar.html" rel="noopener noreferrer"&gt;Release-Kalender&lt;/a&gt; für den 21. Oktober 2026, ein Bugfix-Release 2.0.1 ist für November geplant. Die &lt;a href="https://duckdb.org/2026/08/17/duckdb-20-highlights.html" rel="noopener noreferrer"&gt;Vorschau&lt;/a&gt; nennt über 10.000 Commits seit Version 1.5. Die Neuerungen, die die Nutzung verändern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB als Server.&lt;/strong&gt; Quack wird stabil, und die neue Anweisung &lt;code&gt;CONNECT&lt;/code&gt; richtet deine Sitzung auf eine entfernte DuckDB oder auf PostgreSQL bzw. MySQL aus und schiebt das SQL dorthin, wo es ausgeführt wird.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VARIANT&lt;/code&gt; als vollwertiger Typ.&lt;/strong&gt; Semistrukturierte Daten (etwa JSON-Logs) werden automatisch in Spalten zerlegt. Sie komprimieren und lassen sich abfragen wie typisierte Daten, ohne dass du ein Schema deklarierst.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger&lt;/strong&gt; , &lt;code&gt;BEFORE&lt;/code&gt; und &lt;code&gt;AFTER&lt;/code&gt;, pro Zeile oder pro Anweisung: Audit-Tabellen ohne Anwendungscode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asynchrones I/O&lt;/strong&gt; in der ganzen Engine, mit den größten Gewinnen auf S3 und anderem Netzwerkspeicher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NEAREST&lt;/code&gt;-Joins&lt;/strong&gt; für Top-k-Ähnlichkeitssuche über Embeddings, nützlich, wenn du abwägst, ob du überhaupt &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/brauchst-du-eine-vektordatenbank-postgres-vs-dedizierte-2026" rel="noopener noreferrer"&gt;eine eigene Vektordatenbank brauchst&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ein neues Speicherformat und ein neuer PEG-basierter SQL-Parser&lt;/strong&gt; , dazu wenige Breaking Changes. Von 2.0 geschriebene Dateien nutzen standardmäßig das neue Format; pinne also Versionen, bevor du gemeinsam genutzte Dateien aktualisierst.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wenn dir Stabilität wichtiger ist als Features: Die Linie 1.4 ist das Long-Term-Support-Release mit Support bis November 2026; ab 1.4 ist jede zweite Minor-Version LTS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ein praktischer Einstieg
&lt;/h2&gt;

&lt;p&gt;Du brauchst keinen Migrationsplan, um von DuckDB zu profitieren. Fang klein an:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ersetze den langsamsten pandas-Schritt&lt;/strong&gt; in einem Notebook durch &lt;code&gt;duckdb.sql(...)&lt;/code&gt; auf demselben DataFrame oder derselben Parquet-Datei und lass den Rest, wie er ist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exportiere eine stabile Warehouse-Tabelle&lt;/strong&gt; einmal täglich als Parquet und richte explorative Arbeit auf die Datei statt aufs Warehouse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lass deine SQL-Transformationen in der CI gegen DuckDB laufen&lt;/strong&gt; , damit ein kaputtes Modell im Pull Request scheitert und nicht in Produktion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Erst dann&lt;/strong&gt; entscheide, ob du gemeinsamen Zugriff über MotherDuck, Quack oder DuckLake brauchst oder ob das Warehouse einfach die gemeinsame Wahrheit bleibt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Behalte das Modell im Kopf: &lt;strong&gt;ein Prozess, spaltenorientiert, vektorisiert, liest Dateien dort, wo sie liegen.&lt;/strong&gt; Alles, worin DuckDB glänzt, folgt daraus, und ebenso alles, wofür es nicht gebaut ist. Wenn du es zuerst in die großen Datenbankfamilien einordnen willst, beginne mit &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/sql-vs-nosql-2026-datenbanktypen-acid-base-richtig-waehlen" rel="noopener noreferrer"&gt;SQL vs. NoSQL: Datenbanktypen richtig wählen&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fazit
&lt;/h2&gt;

&lt;p&gt;DuckDB verdient seinen Hype bei einem engen, aber sehr häufigen Problem: Eine Person oder ein Job stellt analytische Fragen an Daten, die auf eine Maschine passen. Dort ist es schneller als pandas, weit schneller als SQLite, bei den meisten Aufgaben mindestens so schnell wie Polars, und es braucht keinerlei Infrastruktur. Es ist kein gemeinsames Warehouse, und wer das ignoriert, landet bei Dateisperren-Fehlern. Nutze es vor und neben BigQuery oder Snowflake, nicht statt ihnen, und behalte die Nebenläufigkeit im Blick: Mit Quack und DuckLake verschiebt sich die Grenze zwischen „Laptop-Datenbank“ und „richtiger Datenbank“ schneller als jeder andere Teil des Datenstacks.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ursprünglich veröffentlicht auf &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/duckdb-erklaert-vs-sqlite-polars-bigquery" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>duckdb</category>
      <category>duckdbvssqlite</category>
      <category>polars</category>
      <category>parquet</category>
    </item>
    <item>
      <title>Das Outbox Pattern: Zuverlässige Events ohne verteilte Transaktionen</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/das-outbox-pattern-zuverlassige-events-ohne-verteilte-transaktionen-50cl</link>
      <guid>https://dev.to/aleksei_aleinikov/das-outbox-pattern-zuverlassige-events-ohne-verteilte-transaktionen-50cl</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1divn7ti0z0alht07rg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1divn7ti0z0alht07rg.webp" alt="Das Outbox Pattern: Zuverlässige Events ohne verteilte Transaktionen" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Jeder Service, der Daten besitzt, muss dem Rest des Systems irgendwann mitteilen, dass etwas passiert ist. Eine Bestellung geht ein, also müssen Billing, Versand und E-Mail davon erfahren. Der naheliegende Code sind zwei Zeilen: Bestellung speichern, dann &lt;code&gt;OrderPlaced&lt;/code&gt; veröffentlichen.&lt;/p&gt;

&lt;p&gt;Diese zwei Zeilen sind die häufigste Quelle für stillen Datenverlust in eventgetriebenen Systemen. Sie schreiben in zwei verschiedene Systeme, und nichts macht das Paar atomar. Das &lt;strong&gt;Outbox Pattern&lt;/strong&gt; (Transactional Outbox) ist die Standardlösung: Schreiben Sie das Event in derselben Transaktion wie die Daten in Ihre eigene Datenbank und liefern Sie es danach aus. Dieser Leitfaden zeigt den Fehler, den es verhindert, das Schema, beide Wege der Auslieferung und die Teile, die die meisten Umsetzungen falsch machen: Duplikate, Reihenfolge und Aufräumen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1divn7ti0z0alht07rg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1divn7ti0z0alht07rg.webp" alt="Das Outbox Pattern: Event mit den Daten committen, danach ausliefern." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Das Dual-Write-Problem
&lt;/h2&gt;

&lt;p&gt;In eine Datenbank speichern und an einen Message Broker senden ist ein &lt;strong&gt;Dual Write&lt;/strong&gt;. Es gibt nur drei mögliche Reihenfolgen, und alle drei scheitern.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8pqnl4bf87a029ezs0z7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8pqnl4bf87a029ezs0z7.webp" alt="Das Dual-Write-Problem: Commit und dann Absturz verliert das Event, Senden und dann Rollback erzeugt ein Phantom-Event, Senden innerhalb der Transaktion hält Locks und endet bei Timeout in einem unbekannten Zustand." width="799" height="453"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Jede Reihenfolge scheitert anders, und Retries reparieren keine davon.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Erst committen, dann senden.&lt;/strong&gt; Der Prozess stürzt nach dem Commit ab, wird neu deployt oder verliert das Netzwerk. Die Bestellung existiert, das Event nicht. Billing bucht nie ab, der Versand verschickt nie, und nirgends steht ein Fehler im Log, denn genau der Code, der es wiederholt hätte, ist gestorben.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Erst senden, dann committen.&lt;/strong&gt; Das Event geht raus, dann scheitert die Transaktion an einem Constraint oder Deadlock und rollt zurück. Consumer handeln jetzt auf Basis einer Bestellung, die nie existiert hat: ein &lt;strong&gt;Phantom-Event&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Innerhalb der Transaktion senden.&lt;/strong&gt; Der Broker ist nicht Teil Ihrer Datenbanktransaktion, das &lt;em&gt;wirkt&lt;/em&gt; also nur sicherer. Zeilensperren bleiben gehalten, während Sie auf das Netzwerk warten, und bei einem Timeout wissen Sie nicht, ob die Nachricht rausging.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Die Lehrbuchantwort ist eine verteilte Transaktion (Two-Phase Commit, XA) über beide Systeme. In der Praxis nehmen die meisten Broker nicht an XA teil, und 2PC koppelt die Verfügbarkeit beider Systeme aneinander. Kafkas eigene Transaktionen umfassen Schreibvorgänge in Kafka-Topics, nicht in Ihre Datenbank. Kafka hat zwar einen optionalen Weg angenommen, mit dem ein Producer an einem extern koordinierten Two-Phase Commit teilnehmen kann (KIP-939), aber auch das ist kein XA: Ihre Anwendung wird zum Koordinator und muss den Transaktionszustand selbst speichern und wiederherstellen.&lt;/p&gt;

&lt;p&gt;Der Ausweg: aufhören, zwei Schreibvorgänge zu haben.&lt;/p&gt;
&lt;h2&gt;
  
  
  Was das Outbox Pattern ist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Das Outbox Pattern speichert ausgehende Events in einer Outbox-Tabelle, in derselben Datenbanktransaktion wie die fachliche Änderung, und veröffentlicht sie danach über ein separates Relay.&lt;/strong&gt; So veröffentlichen Microservices Events zuverlässig und mindestens einmal, ohne verteilte Transaktion.&lt;/p&gt;

&lt;p&gt;Das Pattern, von Chris Richardson auf microservices.io katalogisiert, macht aus zwei Schreibvorgängen einen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Das Event als Zeile schreiben.&lt;/strong&gt; Eine &lt;code&gt;outbox&lt;/code&gt;-Tabelle liegt in &lt;em&gt;derselben&lt;/em&gt; Datenbank wie Ihre Fachdaten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dieselbe Transaktion nutzen.&lt;/strong&gt; Fachliche Änderung und Outbox-Insert werden gemeinsam committet oder gemeinsam zurückgerollt. Das Event existiert genau dann, wenn die Änderung committet wurde.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Danach weiterreichen.&lt;/strong&gt; Ein separater Prozess liest committete Outbox-Zeilen und veröffentlicht sie an den Broker, mit Retries, bis der Broker bestätigt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23o2fbo1foedmw0rj1or.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23o2fbo1foedmw0rj1or.webp" alt="So funktioniert die Outbox: Die Bestellzeile und die Outbox-Zeile werden in einer Datenbanktransaktion geschrieben, ein Relay liest committete Outbox-Zeilen, veröffentlicht sie nach Aggregat-ID geschlüsselt an den Broker, und Consumer deduplizieren anhand der Event-ID." width="799" height="453"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Das Event teilt das Schicksal der Daten. Die Zustellung wird at-least-once, und Consumer deduplizieren.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Die Datenbanktransaktion, der Sie ohnehin vertrauen, ist jetzt der einzige atomare Schritt. Alles nach dem Commit ist eine Retry-Schleife, und Retry-Schleifen lassen sich leicht zuverlässig machen.&lt;/p&gt;

&lt;p&gt;Es ist derselbe Baustein, der am Anfang einer &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/webhooks-erklaert-signatur-retries-idempotenz" rel="noopener noreferrer"&gt;zuverlässigen Webhook-Zustellung&lt;/a&gt; steht und hinter dem &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/eine-million-benachrichtigungen-versenden-ohne-abzustuerzen-2026" rel="noopener noreferrer"&gt;Versand von einer Million Benachrichtigungen ohne Absturz&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Schritt 1: die Outbox-Tabelle
&lt;/h2&gt;

&lt;p&gt;Verwenden Sie die Spaltennamen, die Debezium standardmäßig erwartet. Sie kosten nichts und halten den Weg zu Change Data Capture offen.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;outbox.sql&lt;/em&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&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;aggregatetype&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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;-- bestimmt das Topic, z. B. "order"&lt;/span&gt;

  &lt;span class="n"&gt;aggregateid&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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;-- Nachrichtenschlüssel, hält die Reihenfolge pro Aggregat&lt;/span&gt;

  &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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;-- "OrderPlaced"&lt;/span&gt;

  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&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;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&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;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;

  &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="c1"&gt;-- nur für ein Polling-Relay&lt;/span&gt;

&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Das Polling-Relay betrachtet nur unveröffentlichte Zeilen.&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;outbox_unpublished&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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;published_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Die &lt;code&gt;id&lt;/code&gt; ist die Identität des Events für sein ganzes Leben. Consumer erkennen daran Duplikate, also erzeugen Sie sie in der Anwendung, nicht im Relay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schritt 2: das Event in derselben Transaktion schreiben
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;place-order.sql&lt;/em&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;BEGIN&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;orders&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;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&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="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&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;outbox&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;aggregatetype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aggregateid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&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;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'42'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'OrderPlaced'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="s1"&gt;'{"eventVersion": 1, "orderId": 42, "customerId": 7, "total": 99.00}'&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;Zwei Details zählen mehr, als es scheint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Der Payload ist ein Vertrag, kein Tabellen-Dump.&lt;/strong&gt; Schreiben Sie das Event, das Consumer brauchen, mit expliziter Version, nicht &lt;code&gt;SELECT *&lt;/code&gt; der Bestelltabelle. Ihre Tabelle wird sich ändern; das Event-Schema muss sich kompatibel weiterentwickeln.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Halten Sie die Transaktion kurz.&lt;/strong&gt; Die Outbox fügt einen Insert hinzu. Sie darf keinen Netzwerkaufruf hinzufügen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Schritt 3: die Zeilen weiterreichen
&lt;/h2&gt;

&lt;p&gt;Es gibt zwei Wege, committete Zeilen aus der Tabelle zu holen. Richardson nennt sie &lt;strong&gt;Polling Publisher&lt;/strong&gt; und &lt;strong&gt;Transaction Log Tailing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpsj2sysmj7noam6rph35.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpsj2sysmj7noam6rph35.webp" alt="Polling Publisher gegen Transaction Log Tailing: Polling fragt die Outbox-Tabelle mit SKIP LOCKED ab und markiert Zeilen als veröffentlicht; Log Tailing liest WAL oder Binlog mit Debezium und seinem EventRouter und veröffentlicht an ein Topic, geschlüsselt nach Aggregat-ID." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Mit Polling starten. Zu CDC wechseln, wenn Latenz, Last oder Reihenfolge es verlangen.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Variante A: Polling Publisher
&lt;/h3&gt;

&lt;p&gt;Eine Schleife in Ihrem eigenen Service. Einen Batch beanspruchen, veröffentlichen, als erledigt markieren, alles in einer kurzen Transaktion:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;relay-batch.sql&lt;/em&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&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;aggregatetype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aggregateid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="k"&gt;IS&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;created_at&lt;/span&gt;

&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;

&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- jede Zeile an den Broker senden und auf das Ack warten, dann:&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;published_ids&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;FOR UPDATE SKIP LOCKED&lt;/code&gt; (PostgreSQL ab 9.5, MySQL ab 8.0) lässt mehrere Relay-Instanzen laufen, ohne dass sie um dieselben Zeilen kämpfen. Damit kommen auch die zwei klassischen Fehler von Polling-Relays:&lt;/p&gt;

&lt;p&gt;Achtung&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pollen Sie nie „alles nach der zuletzt gesehenen ID“.&lt;/strong&gt; IDs und Zeitstempel werden beim Insert vergeben, sichtbar werden Zeilen aber erst beim Commit, und Transaktionen committen in beliebiger Reihenfolge. Eine langsame Transaktion kann Zeile 41 committen, nachdem Sie schon an 42 vorbei sind, und ein Poller mit Hochwassermarke überspringt sie für immer. Selektieren Sie stattdessen auf &lt;code&gt;published_at IS NULL&lt;/code&gt;, wie oben.&lt;/p&gt;

&lt;p&gt;Der zweite Fehler ist die Reihenfolge. Mit mehreren Relays und &lt;code&gt;SKIP LOCKED&lt;/code&gt; können zwei Events derselben Bestellung von verschiedenen Instanzen in beliebiger Reihenfolge veröffentlicht werden. Wenn das für Consumer zählt, und meistens tut es das, geben Sie jedem Aggregat genau einen Besitzer: ein einziges Relay oder Relays, die jeweils einen festen Bereich von &lt;code&gt;hash(aggregateid)&lt;/code&gt; beanspruchen.&lt;/p&gt;

&lt;p&gt;Polling bringt bis zu ein Intervall Latenz. In PostgreSQL senken Sie sie, ohne häufiger zu pollen: &lt;code&gt;NOTIFY outbox&lt;/code&gt; in der schreibenden Transaktion, und das Relay lauscht per &lt;code&gt;LISTEN&lt;/code&gt;. Benachrichtigungen werden erst beim Commit zugestellt, das Relay wacht also genau dann auf, wenn es etwas zu lesen gibt, und das Intervall wird zum Sicherheitsnetz.&lt;/p&gt;

&lt;h3&gt;
  
  
  Variante B: Transaction Log Tailing (CDC)
&lt;/h3&gt;

&lt;p&gt;Statt die Tabelle abzufragen, lesen Sie das Transaktionslog der Datenbank: das WAL in PostgreSQL, das Binlog in MySQL. Inserts kommen in Commit-Reihenfolge an, ganz ohne Polling-Abfragen. Das übliche Werkzeug ist &lt;strong&gt;Debezium&lt;/strong&gt; mit seinem &lt;strong&gt;Outbox Event Router&lt;/strong&gt; :&lt;/p&gt;

&lt;p&gt;&lt;em&gt;outbox-connector.properties&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;
&lt;span class="py"&gt;connector.class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.debezium.connector.postgresql.PostgresConnector&lt;/span&gt;

&lt;span class="py"&gt;plugin.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pgoutput&lt;/span&gt;

&lt;span class="py"&gt;table.include.list&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;public.outbox&lt;/span&gt;

&lt;span class="py"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;outbox&lt;/span&gt;

&lt;span class="py"&gt;transforms.outbox.type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.debezium.transforms.outbox.EventRouter&lt;/span&gt;

&lt;span class="py"&gt;transforms.outbox.table.expand.json.payload&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="py"&gt;value.converter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.kafka.connect.json.JsonConverter&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Mit den Standardspalten aus Schritt 1 veröffentlicht der Router jede Zeile an das Topic &lt;code&gt;outbox.event.&amp;lt;aggregatetype&amp;gt;&lt;/code&gt;, nutzt &lt;code&gt;aggregateid&lt;/code&gt; als Nachrichtenschlüssel, sodass alle Events eines Aggregats geordnet in einer Partition landen, und legt die Event-&lt;code&gt;id&lt;/code&gt; zur Deduplizierung in einen Nachrichten-Header.&lt;/p&gt;

&lt;p&gt;Zwei Dinge machen Log Tailing im großen Maßstab attraktiv:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Die Tabelle kann leer bleiben.&lt;/strong&gt; Fügen Sie die Outbox-Zeile ein und löschen Sie sie in derselben Transaktion. Der Insert steht trotzdem im Log, Debezium veröffentlicht ihn, und der Router ignoriert Deletes. Kein Aufräumjob, kein Bloat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keine Abfragelast.&lt;/strong&gt; Der Connector liest das Log, das die Datenbank ohnehin schreibt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Und eine Sache verdient Respekt: In PostgreSQL nutzt CDC einen &lt;strong&gt;Replication Slot&lt;/strong&gt; , und die Datenbank hält WAL dafür vor, bis der Connector es bestätigt. Ein Connector, der ein Wochenende ausfällt, kann die Platte füllen. Alarmieren Sie auf Slot-Lag und begrenzen Sie ihn mit &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; (ab PostgreSQL 13).&lt;/p&gt;

&lt;p&gt;Tipp&lt;/p&gt;

&lt;p&gt;Wenn die &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/data/postgres-vs-mysql-2026-vergleich" rel="noopener noreferrer"&gt;Unterschiede zwischen PostgreSQL und MySQL&lt;/a&gt; für Ihre Plattform zählen, zählen sie auch hier: Logical Decoding mit &lt;code&gt;pgoutput&lt;/code&gt; auf der einen Seite, zeilenbasiertes Binlog auf der anderen. Debezium unterstützt beide.&lt;/p&gt;

&lt;h2&gt;
  
  
  At-least-once: Consumer idempotent machen
&lt;/h2&gt;

&lt;p&gt;Die Outbox garantiert, dass ein Event nie verloren geht. Sie garantiert &lt;strong&gt;nicht&lt;/strong&gt; , dass es genau einmal ankommt. Das Relay kann einen Batch veröffentlichen und abstürzen, bevor es &lt;code&gt;published_at&lt;/code&gt; setzt. Nach dem Neustart veröffentlicht es dieselben Events erneut. Eine erneute Zustellung durch den Broker oder ein Consumer-Absturz vor dem Ack bewirken dasselbe.&lt;/p&gt;

&lt;p&gt;Jeder Consumer muss also deduplizieren, und der robuste Weg ist eine &lt;strong&gt;Inbox&lt;/strong&gt; : Die Event-ID wird in derselben Transaktion gespeichert wie die eigentliche Änderung.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;consume-order-placed.sql&lt;/em&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;BEGIN&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;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&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="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 0 Zeilen eingefügt: Duplikat, committen und bestätigen, ohne etwas zu tun.&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;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&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="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&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;span class="c1"&gt;-- die Nachricht erst nach dem Commit bestätigen&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;At-least-once-Zustellung plus idempotente Consumer ist das, was in der Praxis mit „exactly-once“ gemeint ist. Dieselbe Technik schützt HTTP-APIs; die Details stehen in &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/idempotenz-in-der-praxis-api-retries-2026" rel="noopener noreferrer"&gt;Idempotenz in der Praxis&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reihenfolge: was Sie versprechen können und was nicht
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pro Aggregat: ja.&lt;/strong&gt; Schlüsseln Sie jede Nachricht nach &lt;code&gt;aggregateid&lt;/code&gt;. Kafka hält die Reihenfolge innerhalb einer Partition, Google Pub/Sub hat Ordering Keys für denselben Zweck. Alle Events zu Bestellung 42 kommen in Commit-Reihenfolge an, solange ein einziges Relay diesen Schlüssel besitzt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global: nein.&lt;/strong&gt; Events zu Bestellung 42 und Bestellung 17 können in beliebiger relativer Reihenfolge ankommen. Bauen Sie Consumer so, dass das in Ordnung ist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trotzdem absichern.&lt;/strong&gt; Legen Sie eine Version oder Sequenznummer pro Aggregat in den Payload. Ein Consumer, der Version 5 nach Version 6 sieht, kann sie ignorieren, statt neueren Zustand zu überschreiben.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Die Outbox betreiben
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Aufräumen.&lt;/strong&gt; Ein Polling-Relay hinterlässt veröffentlichte Zeilen. Löschen Sie sie nach einem kurzen Audit-Fenster in Batches, oder partitionieren Sie die Tabelle bei hohem Volumen nach Tag und verwerfen alte Partitionen, was im Vergleich zu großen &lt;code&gt;DELETE&lt;/code&gt;-Anweisungen und der Vacuum-Arbeit, die sie auslösen, nichts kostet. Mit CDC löschen Sie in derselben Transaktion, wie oben beschrieben.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring.&lt;/strong&gt; Eine Metrik sagt fast alles: &lt;strong&gt;das Alter des ältesten unveröffentlichten Events&lt;/strong&gt;. Mit Polling ist das &lt;code&gt;now() - min(created_at) WHERE published_at IS NULL&lt;/code&gt;, mit CDC der Replikations-Lag. Alarmieren Sie, wenn es einige Minuten überschreitet; dieselbe Regel fängt ein totes Relay, einen Broker-Ausfall und ein Poison Event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Poison Events.&lt;/strong&gt; Ein Event, das der Broker ablehnt (zu groß, ungültiges Schema), blockiert die Warteschlange für seinen Schlüssel. Zählen Sie Versuche pro Zeile und verschieben Sie sie nach einem Limit in eine Dead-Letter-Tabelle mit Alarm, statt endlos zu wiederholen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing.&lt;/strong&gt; Speichern Sie den Trace-Kontext in der Outbox-Zeile und stellen Sie ihn im Relay wieder her, damit ein Trace dem Request vom API-Aufruf über den Broker bis in jeden Consumer folgt. Debeziums Router unterstützt das über eine Tracing-Spalte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outbox-Implementierungen in Java, .NET, Go und MongoDB
&lt;/h2&gt;

&lt;p&gt;Das Relay müssen Sie selten selbst schreiben. Das Pattern steckt in den Frameworks, die die meisten Teams ohnehin nutzen:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;Fertige Outbox&lt;/th&gt;
&lt;th&gt;Hinweis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Java, Spring&lt;/td&gt;
&lt;td&gt;Spring Modulith Event Publication Registry&lt;/td&gt;
&lt;td&gt;Laut eigener Doku fehlen der nativen Externalisierung fortgeschrittene Outbox-Funktionen; 2.1 bringt Namastack Outbox und JobRunr als Backends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Eventuate Tram&lt;/td&gt;
&lt;td&gt;Von Chris Richardson, dem Autor des Pattern-Katalogs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.NET (C#)&lt;/td&gt;
&lt;td&gt;MassTransit Transactional Outbox, NServiceBus Outbox, Wolverine, CAP&lt;/td&gt;
&lt;td&gt;MassTransit und NServiceBus deduplizieren auch auf Consumer-Seite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Watermill Forwarder&lt;/td&gt;
&lt;td&gt;Veröffentlicht in Ihrer Transaktion in eine SQL-Tabelle und leitet an den echten Broker weiter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jede Sprache&lt;/td&gt;
&lt;td&gt;Debezium (Kafka Connect oder Debezium Server)&lt;/td&gt;
&lt;td&gt;Debezium Server sendet ohne Kafka an Google Pub/Sub, Amazon Kinesis und andere&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;MongoDB&lt;/strong&gt; unterstützt Multi-Document-Transaktionen auf Replica Sets, die Outbox funktioniert also genauso: Bestellung und Outbox-Dokument in einer Session-Transaktion schreiben. Debezium hat einen eigenen MongoDB Outbox Event Router, weil der relationale nicht mit dem MongoDB-Connector funktioniert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud-Datenbanken&lt;/strong&gt; haben oft ein Log, das Sie statt Polling lesen können: DynamoDB Streams nach einem &lt;code&gt;TransactWriteItems&lt;/code&gt;, das Item und Event gemeinsam schreibt, oder Spanner Change Streams auf einer Outbox-Tabelle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outbox oder etwas anderes?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ansatz&lt;/th&gt;
&lt;th&gt;Atomar mit Ihren Daten&lt;/th&gt;
&lt;th&gt;Zusätzliche Infrastruktur&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Erst committen, dann senden&lt;/td&gt;
&lt;td&gt;Nein&lt;/td&gt;
&lt;td&gt;Keine&lt;/td&gt;
&lt;td&gt;Verliert Events bei Absturz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two-Phase Commit (XA)&lt;/td&gt;
&lt;td&gt;Ja&lt;/td&gt;
&lt;td&gt;XA-fähiger Broker&lt;/td&gt;
&lt;td&gt;Die meisten Broker unterstützen es nicht; koppelt Verfügbarkeit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Transactional Outbox&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Ja&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Ein Relay&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;At-least-once, Consumer müssen deduplizieren&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDC auf Fachtabellen&lt;/td&gt;
&lt;td&gt;Ja&lt;/td&gt;
&lt;td&gt;CDC-Pipeline&lt;/td&gt;
&lt;td&gt;Ihr internes Schema wird zum öffentlichen Vertrag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event Sourcing&lt;/td&gt;
&lt;td&gt;Ja, das Event ist der Zustand&lt;/td&gt;
&lt;td&gt;Event Store&lt;/td&gt;
&lt;td&gt;Ändert Ihr gesamtes Persistenzmodell&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Die Outbox ist die Variante, die einen bewussten Event-Vertrag behält und nichts Exotischeres braucht als eine lokale Transaktion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wann Sie keine Outbox brauchen
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Niemand außerhalb der Datenbank muss davon erfahren.&lt;/strong&gt; Ist die Folgearbeit ein Job in einer Queue-Tabelle in &lt;em&gt;derselben&lt;/em&gt; Datenbank, wird die Job-Zeile bereits atomar mit Ihren Daten committet. Sie haben eine Outbox ohne den Namen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sie nutzen bereits Event Sourcing.&lt;/strong&gt; Das Anhängen an den Event Store &lt;em&gt;ist&lt;/em&gt; die Zustandsänderung; veröffentlichen Sie aus dem Store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ein verlorenes Event ist wirklich egal.&lt;/strong&gt; Analytics-Pings und Best-Effort-Cache-Warming vertragen den seltenen Verlust. Fachliche Fakten wie Bestellungen, Zahlungen und Berechtigungen nicht.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Das Pattern passt auch direkt dazu, &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/monolith-vs-microservices-2026-ehrliche-abwaegung" rel="noopener noreferrer"&gt;wie eine Microservices-Architektur funktioniert&lt;/a&gt;: Services besitzen ihre Daten, und asynchrone Events sind der Weg, über den der Rest des Systems von Änderungen erfährt, ohne die Datenbank eines anderen Service zu lesen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Das Fazit
&lt;/h2&gt;

&lt;p&gt;Der Fehler, den die Outbox behebt, ist im Test unsichtbar und in Produktion unvermeidlich: ein Absturz zwischen „gespeichert“ und „veröffentlicht“. Die Lösung ist, das Event nicht mehr als zweiten Schreibvorgang zu behandeln. Committen Sie es mit den Daten, in der Transaktion, der Sie ohnehin vertrauen, und lassen Sie ein Relay es mit Retries zustellen.&lt;/p&gt;

&lt;p&gt;Fangen Sie einfach an: eine Outbox-Tabelle mit Debezium-kompatiblen Spalten, ein Polling-Relay mit &lt;code&gt;SKIP LOCKED&lt;/code&gt;, das unveröffentlichte Zeilen selektiert statt an der letzten ID vorbeizulesen, Consumer, die anhand der Event-ID deduplizieren, und ein Alarm auf das Alter des ältesten unveröffentlichten Events. Wechseln Sie zu Log Tailing, wenn Latenz oder Last es verlangen. Ihr Schema ist schon bereit.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Weiterlesen: &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/webhooks-erklaert-signatur-retries-idempotenz" rel="noopener noreferrer"&gt;Webhooks erklärt: Signatur, Retries und Idempotenz&lt;/a&gt; · &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/eine-million-benachrichtigungen-versenden-ohne-abzustuerzen-2026" rel="noopener noreferrer"&gt;Eine Million Benachrichtigungen versenden, ohne abzustürzen&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Ursprünglich veröffentlicht auf &lt;a href="https://www.alekseialeinikov.com/de/blog/topics/architecture/outbox-pattern-erklaert" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>outboxpattern</category>
      <category>transactionaloutbox</category>
      <category>eventdrivenarchitect</category>
      <category>microservices</category>
    </item>
    <item>
      <title>The Outbox Pattern: Reliable Events Without Distributed Transactions</title>
      <dc:creator>Aleksei Aleinikov</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/aleksei_aleinikov/the-outbox-pattern-reliable-events-without-distributed-transactions-1323</link>
      <guid>https://dev.to/aleksei_aleinikov/the-outbox-pattern-reliable-events-without-distributed-transactions-1323</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1divn7ti0z0alht07rg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1divn7ti0z0alht07rg.webp" alt="The Outbox Pattern: Reliable Events Without Distributed Transactions" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every service that owns data eventually has to tell the rest of the system that something happened. An order is placed, so billing, shipping and email need to know. The obvious code is two lines: save the order, then publish &lt;code&gt;OrderPlaced&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those two lines are the most common source of silent data loss in event-driven systems. They write to two different systems, and nothing makes the pair atomic. The &lt;strong&gt;transactional outbox pattern&lt;/strong&gt; is the standard fix: write the event into your own database in the same transaction as the data, and ship it afterwards. This guide covers the failure it prevents, the schema, both ways to relay events, and the parts most implementations get wrong: duplicates, ordering and cleanup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1divn7ti0z0alht07rg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1divn7ti0z0alht07rg.webp" alt="The Outbox Pattern: commit the event with the data, ship it afterwards." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The dual-write problem
&lt;/h2&gt;

&lt;p&gt;Saving to a database and publishing to a message broker is a &lt;strong&gt;dual write&lt;/strong&gt;. There are only three ways to order it, and all three break.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8pqnl4bf87a029ezs0z7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8pqnl4bf87a029ezs0z7.webp" alt="The dual-write problem: commit then crash loses the event, publish then roll back sends a phantom event, and publishing inside the transaction holds locks and ends in an unknown state on timeout." width="799" height="453"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Each ordering fails differently, and retries cannot fix any of them.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Commit, then publish.&lt;/strong&gt; The process crashes, is redeployed or loses the network after the commit. The order exists; the event does not. Billing never charges, shipping never ships, and no error is logged anywhere, because the code that would have retried is the code that died.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish, then commit.&lt;/strong&gt; The event goes out, then the transaction fails on a constraint or a deadlock and rolls back. Consumers now act on an order that never existed: a &lt;strong&gt;phantom event&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish inside the transaction.&lt;/strong&gt; The broker is not part of your database transaction, so this only &lt;em&gt;looks&lt;/em&gt; safer. Row locks stay held while you wait on the network, and when the call times out you cannot tell whether the message went out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The textbook answer is a distributed transaction (two-phase commit, XA) spanning both systems. In practice most brokers do not take part in XA, and 2PC couples the availability of both systems to each other. Kafka’s own transactions cover writes to Kafka topics, not to your database. Kafka has accepted an opt-in way for a producer to join an externally coordinated two-phase commit (KIP-939), but it is not XA either: your application becomes the coordinator and has to store and recover transaction state itself.&lt;/p&gt;

&lt;p&gt;The way out is to stop having two writes.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the transactional outbox pattern is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The transactional outbox pattern stores outgoing events in an outbox table, in the same database transaction as the business change, and publishes them afterwards with a separate relay.&lt;/strong&gt; You get reliable, at-least-once event publishing between microservices without a distributed transaction.&lt;/p&gt;

&lt;p&gt;The pattern, catalogued by Chris Richardson on microservices.io, turns two writes into one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the event as a row.&lt;/strong&gt; An &lt;code&gt;outbox&lt;/code&gt; table lives in the &lt;em&gt;same&lt;/em&gt; database as your business data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the same transaction.&lt;/strong&gt; The business change and the outbox insert commit or roll back together. The event exists if and only if the change committed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relay it afterwards.&lt;/strong&gt; A separate process reads committed outbox rows and publishes them to the broker, retrying until the broker acknowledges.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23o2fbo1foedmw0rj1or.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23o2fbo1foedmw0rj1or.webp" alt="How the outbox works: the orders row and the outbox row are written in one database transaction, a relay reads committed outbox rows, publishes them to the broker keyed by aggregate ID, and consumers deduplicate by event ID." width="799" height="453"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The event shares the fate of the data. Delivery becomes at-least-once, and consumers deduplicate.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The database transaction you already trust is now the only atomic step. Everything after the commit is a retry loop, and retry loops are easy to make reliable.&lt;/p&gt;

&lt;p&gt;It is the same building block that sits at the start of a &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/webhooks-explained-signatures-retries-idempotency" rel="noopener noreferrer"&gt;reliable webhook delivery pipeline&lt;/a&gt; and behind &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/send-one-million-notifications-without-falling-over-2026" rel="noopener noreferrer"&gt;sending a million notifications without falling over&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: the outbox table
&lt;/h2&gt;

&lt;p&gt;Use the column names Debezium expects by default. They cost nothing and keep the door open to change data capture later.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;outbox.sql&lt;/em&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&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;aggregatetype&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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;-- routes to a topic, e.g. "order"&lt;/span&gt;

  &lt;span class="n"&gt;aggregateid&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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;-- message key, keeps per-aggregate order&lt;/span&gt;

  &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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;-- "OrderPlaced"&lt;/span&gt;

  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&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;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&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;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;

  &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="c1"&gt;-- only used by a polling relay&lt;/span&gt;

&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- The polling relay only ever looks at unpublished rows.&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;outbox_unpublished&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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;published_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; is the event’s identity for its whole life. Consumers use it to detect duplicates, so generate it in the application, not in the relay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: write the event in the same transaction
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;place-order.sql&lt;/em&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;BEGIN&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;orders&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;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&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="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&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;outbox&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;aggregatetype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aggregateid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&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;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'42'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'OrderPlaced'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="s1"&gt;'{"eventVersion": 1, "orderId": 42, "customerId": 7, "total": 99.00}'&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;Two details matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The payload is a contract, not a row dump.&lt;/strong&gt; Write the event consumers need, with an explicit version, not &lt;code&gt;SELECT *&lt;/code&gt; of the orders table. Your table will change; the event schema has to evolve compatibly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the transaction short.&lt;/strong&gt; The outbox adds one insert. It must not add a network call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: relay the rows
&lt;/h2&gt;

&lt;p&gt;There are two ways to get committed rows out of the table. Richardson names them &lt;strong&gt;polling publisher&lt;/strong&gt; and &lt;strong&gt;transaction log tailing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpsj2sysmj7noam6rph35.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpsj2sysmj7noam6rph35.webp" alt="Polling publisher vs transaction log tailing: polling queries the outbox table with SKIP LOCKED and marks rows published; log tailing reads the WAL or binlog with Debezium and its EventRouter and publishes to a topic keyed by aggregate ID." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Start with polling. Move to CDC when latency, load or ordering demands it.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Option A: polling publisher
&lt;/h3&gt;

&lt;p&gt;A loop in your own service. Claim a batch, publish it, mark it done, all in a short transaction:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;relay-batch.sql&lt;/em&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&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;aggregatetype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aggregateid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt;

&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="k"&gt;IS&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;created_at&lt;/span&gt;

&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;

&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- publish each row to the broker and wait for the ack, then:&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;published_ids&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;FOR UPDATE SKIP LOCKED&lt;/code&gt; (PostgreSQL 9.5+, MySQL 8.0+) lets several relay instances run without fighting over the same rows. It also introduces the two classic bugs of polling relays:&lt;/p&gt;

&lt;p&gt;Warning&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never poll “everything after the last ID I saw”.&lt;/strong&gt; IDs and timestamps are assigned when a row is inserted, but rows become visible when they commit, and transactions commit out of order. A slow transaction can commit row 41 after you already moved past 42, and a high-water-mark poller skips it forever. Select on &lt;code&gt;published_at IS NULL&lt;/code&gt; instead, as above.&lt;/p&gt;

&lt;p&gt;The second bug is ordering. With several relays and &lt;code&gt;SKIP LOCKED&lt;/code&gt;, two events for the same order can be published by different instances in either order. If consumers care, and they usually do, give each aggregate a single owner: one relay, or relays that each claim a fixed range of &lt;code&gt;hash(aggregateid)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Polling adds latency of up to one interval. In PostgreSQL you can cut it without polling harder: issue &lt;code&gt;NOTIFY outbox&lt;/code&gt; in the writing transaction and have the relay &lt;code&gt;LISTEN&lt;/code&gt;. Notifications are only delivered when the transaction commits, so the relay wakes up exactly when there is something to read, and the interval becomes a safety net.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: transaction log tailing (CDC)
&lt;/h3&gt;

&lt;p&gt;Instead of querying the table, read the database’s own transaction log: the WAL in PostgreSQL, the binlog in MySQL. Inserts arrive in commit order, with no polling queries at all. The common tool is &lt;strong&gt;Debezium&lt;/strong&gt; with its &lt;strong&gt;Outbox Event Router&lt;/strong&gt; :&lt;/p&gt;

&lt;p&gt;&lt;em&gt;outbox-connector.properties&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;
&lt;span class="py"&gt;connector.class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.debezium.connector.postgresql.PostgresConnector&lt;/span&gt;

&lt;span class="py"&gt;plugin.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pgoutput&lt;/span&gt;

&lt;span class="py"&gt;table.include.list&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;public.outbox&lt;/span&gt;

&lt;span class="py"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;outbox&lt;/span&gt;

&lt;span class="py"&gt;transforms.outbox.type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.debezium.transforms.outbox.EventRouter&lt;/span&gt;

&lt;span class="py"&gt;transforms.outbox.table.expand.json.payload&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="py"&gt;value.converter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.kafka.connect.json.JsonConverter&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;With the default columns from Step 1, the router publishes each row to the topic &lt;code&gt;outbox.event.&amp;lt;aggregatetype&amp;gt;&lt;/code&gt;, uses &lt;code&gt;aggregateid&lt;/code&gt; as the message key, so all events of one aggregate land in one partition in order, and puts the event &lt;code&gt;id&lt;/code&gt; into a message header for deduplication.&lt;/p&gt;

&lt;p&gt;Two things make log tailing attractive at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The table can stay empty.&lt;/strong&gt; Insert the outbox row and delete it in the same transaction. The insert is still in the log, so Debezium publishes it, and the router ignores deletes. No cleanup job, no bloat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No query load.&lt;/strong&gt; The connector reads the log the database writes anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one thing to respect: in PostgreSQL, CDC uses a &lt;strong&gt;replication slot&lt;/strong&gt; , and the database keeps WAL for it until the connector confirms it. A connector that is down for a weekend can fill the disk. Alert on slot lag, and cap it with &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; (PostgreSQL 13+).&lt;/p&gt;

&lt;p&gt;Tip&lt;/p&gt;

&lt;p&gt;If the &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/data/postgres-vs-mysql-2026" rel="noopener noreferrer"&gt;differences between PostgreSQL and MySQL&lt;/a&gt; matter for your platform, they matter here too: logical decoding with &lt;code&gt;pgoutput&lt;/code&gt; on one side, row-based binlog on the other. Debezium supports both.&lt;/p&gt;

&lt;h2&gt;
  
  
  At-least-once: make consumers idempotent
&lt;/h2&gt;

&lt;p&gt;The outbox guarantees that an event is never lost. It does &lt;strong&gt;not&lt;/strong&gt; guarantee that it arrives once. The relay can publish a batch and crash before it records &lt;code&gt;published_at&lt;/code&gt;. After a restart it publishes the same events again. A broker redelivery or a consumer crash before its ack does the same.&lt;/p&gt;

&lt;p&gt;So every consumer must deduplicate, and the robust way is an &lt;strong&gt;inbox&lt;/strong&gt; : record the event ID in the same transaction as the side effect.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;consume-order-placed.sql&lt;/em&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;BEGIN&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;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&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="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 0 rows inserted: duplicate, commit and acknowledge without doing anything.&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;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&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="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&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;span class="c1"&gt;-- acknowledge the message only after the commit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;At-least-once delivery plus idempotent consumers is what people mean by “exactly-once” in practice. The same technique protects HTTP APIs; the details are in &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/idempotency-in-practice-api-retries-2026" rel="noopener noreferrer"&gt;idempotency in practice&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ordering: what you can and cannot promise
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per aggregate: yes.&lt;/strong&gt; Key every message by &lt;code&gt;aggregateid&lt;/code&gt;. Kafka keeps order within a partition, and Google Pub/Sub has ordering keys for the same purpose. All events for order 42 arrive in the order they were committed, as long as a single relay owns that key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Globally: no.&lt;/strong&gt; Events for order 42 and order 17 can arrive in any relative order. Design consumers so that is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defend anyway.&lt;/strong&gt; Put a version or sequence number per aggregate in the payload. A consumer that sees version 5 after version 6 can ignore it instead of overwriting newer state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Operating the outbox
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cleanup.&lt;/strong&gt; A polling relay leaves published rows behind. Delete them in batches after a short audit window, or for high volume partition the table by day and drop old partitions, which costs nothing compared with large &lt;code&gt;DELETE&lt;/code&gt; statements and the vacuum work they cause. With CDC, delete in the same transaction as described above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring.&lt;/strong&gt; One metric tells you almost everything: &lt;strong&gt;the age of the oldest unpublished event&lt;/strong&gt;. With polling that is &lt;code&gt;now() - min(created_at) WHERE published_at IS NULL&lt;/code&gt;; with CDC it is replication lag. Alert when it exceeds a few minutes; it catches a dead relay, a broker outage and a poison event with the same rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Poison events.&lt;/strong&gt; An event the broker rejects (too large, invalid schema) blocks the queue for its key. Count attempts per row, and after a limit move it to a dead-letter table and alert, instead of retrying forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing.&lt;/strong&gt; Store the trace context in the outbox row and restore it in the relay, so a trace follows the request from the API call through the broker into every consumer. Debezium’s router supports this with a tracing column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outbox implementations in Java, .NET, Go and MongoDB
&lt;/h2&gt;

&lt;p&gt;You rarely need to write the relay from scratch. The pattern ships in the frameworks most teams already use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;Ready-made outbox&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Java, Spring&lt;/td&gt;
&lt;td&gt;Spring Modulith Event Publication Registry&lt;/td&gt;
&lt;td&gt;Its own docs say the native externalization lacks advanced outbox features; 2.1 adds Namastack Outbox and JobRunr as backends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Eventuate Tram&lt;/td&gt;
&lt;td&gt;From Chris Richardson, the author of the pattern catalogue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.NET (C#)&lt;/td&gt;
&lt;td&gt;MassTransit Transactional Outbox, NServiceBus Outbox, Wolverine, CAP&lt;/td&gt;
&lt;td&gt;MassTransit and NServiceBus also deduplicate on the consumer side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Watermill Forwarder&lt;/td&gt;
&lt;td&gt;Publishes into an SQL table in your transaction, forwards to the real broker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any language&lt;/td&gt;
&lt;td&gt;Debezium (Kafka Connect or Debezium Server)&lt;/td&gt;
&lt;td&gt;Debezium Server sends to Google Pub/Sub, Amazon Kinesis and others without Kafka&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;MongoDB&lt;/strong&gt; supports multi-document transactions on replica sets, so the outbox works the same way: write the order and the outbox document in one session transaction. Debezium has a dedicated MongoDB outbox event router, because the relational one does not work with the MongoDB connector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud databases&lt;/strong&gt; often have a log you can tail instead of polling: DynamoDB Streams after a &lt;code&gt;TransactWriteItems&lt;/code&gt; that writes the item and the event together, or Spanner change streams on an outbox table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outbox or something else?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Atomic with your data&lt;/th&gt;
&lt;th&gt;Extra infrastructure&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Commit, then publish&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Loses events on crash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two-phase commit (XA)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;XA-capable broker&lt;/td&gt;
&lt;td&gt;Most brokers don’t support it; couples availability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Transactional outbox&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;A relay&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;At-least-once, consumers must dedupe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDC on business tables&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;CDC pipeline&lt;/td&gt;
&lt;td&gt;Your internal schema becomes the public contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event sourcing&lt;/td&gt;
&lt;td&gt;Yes, the event is the data&lt;/td&gt;
&lt;td&gt;Event store&lt;/td&gt;
&lt;td&gt;Changes your whole persistence model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The outbox is the option that keeps a deliberate event contract while needing nothing more exotic than a local transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you don’t need an outbox
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nobody outside the database needs to know.&lt;/strong&gt; If the follow-up work is a job in a queue table in the &lt;em&gt;same&lt;/em&gt; database, the job row already commits atomically with your data. You have an outbox without the name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already do event sourcing.&lt;/strong&gt; The append to the event store &lt;em&gt;is&lt;/em&gt; the state change; publish from the store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Losing an event is genuinely fine.&lt;/strong&gt; Analytics pings and best-effort cache warming can tolerate the rare loss. Business facts like orders, payments and permissions cannot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern also fits naturally into &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/monolith-vs-microservices-2026-honest-trade-offs" rel="noopener noreferrer"&gt;how a microservices architecture works&lt;/a&gt;: services own their data, and asynchronous events are how the rest of the system learns about changes without reading another service’s database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;The bug the outbox fixes is invisible in testing and inevitable in production: a crash between “saved” and “published”. The fix is to stop treating the event as a second write. Commit it with the data, in the transaction you already trust, and let a relay deliver it with retries.&lt;/p&gt;

&lt;p&gt;Start simple: an outbox table with Debezium-compatible columns, a polling relay with &lt;code&gt;SKIP LOCKED&lt;/code&gt; that selects unpublished rows rather than reading past the last ID, consumers that deduplicate by event ID, and one alert on the age of the oldest unpublished event. Move to log tailing when latency or load asks for it. Your schema is already ready.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Read next: &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/webhooks-explained-signatures-retries-idempotency" rel="noopener noreferrer"&gt;Webhooks explained: signatures, retries and idempotency&lt;/a&gt; · &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/send-one-million-notifications-without-falling-over-2026" rel="noopener noreferrer"&gt;Send a million notifications without falling over&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.alekseialeinikov.com/en/blog/topics/architecture/transactional-outbox-pattern" rel="noopener noreferrer"&gt;alekseialeinikov.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>outboxpattern</category>
      <category>transactionaloutbox</category>
      <category>eventdrivenarchitect</category>
      <category>microservices</category>
    </item>
  </channel>
</rss>
