<?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: Marcus</title>
    <description>The latest articles on DEV Community by Marcus (@marcus1968).</description>
    <link>https://dev.to/marcus1968</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%2F4009616%2F12158dea-c717-41b8-be68-130eacdf65ed.jpg</url>
      <title>DEV Community: Marcus</title>
      <link>https://dev.to/marcus1968</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marcus1968"/>
    <language>en</language>
    <item>
      <title>I Gave Claude Code 27 Rule Files Instead of One CLAUDE.md</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Sat, 25 Jul 2026 16:49:51 +0000</pubDate>
      <link>https://dev.to/marcus1968/i-gave-claude-code-27-rule-files-instead-of-one-claudemd-4nl0</link>
      <guid>https://dev.to/marcus1968/i-gave-claude-code-27-rule-files-instead-of-one-claudemd-4nl0</guid>
      <description>&lt;p&gt;There is a moment when a CLAUDE.md tips over. That moment does not sit on any calendar, it shows in behavior: the file keeps getting longer, and the rules inside it keep getting followed less. Every new convention you write in dilutes the ones already there. That is exactly where the project behind this article stood — and the answer was not a better CLAUDE.md, but its dissolution into individual rule files. Today there are 27 of them.&lt;/p&gt;

&lt;p&gt;This article is the experience report: how Claude Code rules need to be structured so they hold up over months, what a single rule file must carry, and why the most important parts are not the rules themselves. They are the don'ts and the inventories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key points up front:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One file per convention&lt;/strong&gt; — 27 rule files instead of one CLAUDE.md, each with a single topic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anatomy of a rule that holds:&lt;/strong&gt; rule, reasoning, don'ts, inventory, cross-references.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don'ts beat prescriptions&lt;/strong&gt; — at least in the DI² project: a negative example is concrete and recognizable, a prescription competes against the training prior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventories are the drift radar:&lt;/strong&gt; the part of a rule that claims an actual state of the code — and therefore the first to stand out when code and documentation diverge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;paths:&lt;/code&gt; scoping&lt;/strong&gt; lets large rules load only where they apply — 3 of the 27 files use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The honest downside:&lt;/strong&gt; maintenance effort, rule conflicts, and a state in which the documentation deliberately runs ahead of the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites.&lt;/strong&gt; A project with Claude Code and a &lt;code&gt;.claude/rules/&lt;/code&gt; directory. The pattern transfers to any coding agent that loads convention files into its context — Cursor rules or comparable mechanisms in other tools work on the same principle. What loads into context when, and what that costs, is covered by the sibling article &lt;a href="https://sql.marcus-belz.de/en/claude-code-skills-vs-rules/" rel="noopener noreferrer"&gt;Skills vs. Rules in Claude Code&lt;/a&gt; — this article starts one level earlier: at the question of what the rule files themselves have to look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Starting Point: One File That Kept Growing
&lt;/h2&gt;

&lt;p&gt;The project behind the numbers is DI², an ETL generator built on Next.js and PostgreSQL, its code written almost entirely AI-assisted with Claude Code. In the beginning, all conventions lived where every Claude Code project collects them first: in the CLAUDE.md. That works as long as the file is short. It stops working once the file becomes a container in which database conventions, color tokens and commit rules all sit side by side.&lt;/p&gt;

&lt;p&gt;This effect is hard to measure, but it is easy to feel. It also matches what research shows about long contexts: language models make measurably worse use of information sitting in the middle of long inputs (&lt;a href="https://arxiv.org/abs/2307.03172" rel="noopener noreferrer"&gt;"Lost in the Middle", Liu et al. 2023&lt;/a&gt;). In my experience, a rule sitting in line 40 of a long file gets followed less than the same rule in its own, topically named file. A second effect has less to do with the model than with the humans: in a 400-line file, even the author cannot find a rule again when they want to check whether it still holds. Why a single file does not scale structurally, and which loading mechanics sit behind that, belongs to the &lt;a href="https://sql.marcus-belz.de/en/claude-code-skills-vs-rules/" rel="noopener noreferrer"&gt;skills-vs-rules article&lt;/a&gt; — this one is about what comes after.&lt;/p&gt;

&lt;p&gt;The consequence in the project: a &lt;code&gt;.claude/rules/&lt;/code&gt; directory holding 27 rule files, one per convention (as of July 22, 2026). The same count holds for the public edition of this rule structure, the &lt;a href="https://github.com/marcusbelz/di2-starter-kit" rel="noopener noreferrer"&gt;di2-starter-kit on GitHub&lt;/a&gt; — you can verify it there, not counting the subfolder READMEs. There is a file for the table conventions, one for dialogs, one for loading states, one for the security model. The directory keeps growing with the project. In the very week this article was written, three new files arrived, for views, triggers and database policies. A rule system is finished when the project is finished, which is to say never.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a Rule That Holds
&lt;/h2&gt;

&lt;p&gt;After several months with this system, a fixed structure has emerged. A rule file that holds consists of five parts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# &amp;lt;Convention&amp;gt; (&amp;lt;project&amp;gt;)&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; One-sentence summary: What does this rule enforce, and where does it apply?&lt;/span&gt;

&lt;span class="gu"&gt;## The Rule&lt;/span&gt;

Every &lt;span class="nt"&gt;&amp;lt;structure&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="na"&gt;procedure&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;does&lt;/span&gt; &lt;span class="na"&gt;exactly&lt;/span&gt; &lt;span class="na"&gt;one&lt;/span&gt; &lt;span class="na"&gt;thing&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stated&lt;/span&gt; &lt;span class="na"&gt;imperatively&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;.
One convention per file — do not mix topics.

&lt;span class="gu"&gt;## Reasoning&lt;/span&gt;

Why this rule exists: the concrete incident, bug or review finding
that triggered it. A rule without a reason gets weighed away in
trade-offs — the reason is part of the rule, not decoration.

&lt;span class="gu"&gt;## Don'ts&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; ❌ &lt;span class="sb"&gt;`&amp;lt;concrete negative example from your own code&amp;gt;`&lt;/span&gt; — why it drifts.
&lt;span class="p"&gt;-&lt;/span&gt; ❌ &lt;span class="sb"&gt;`&amp;lt;second negative example&amp;gt;`&lt;/span&gt; — what applies instead (with the target spelling).

&lt;span class="gu"&gt;## Inventory&lt;/span&gt;

| Usage site | File | Status |
|---|---|---|
| &lt;span class="nt"&gt;&amp;lt;Site&lt;/span&gt; &lt;span class="na"&gt;A&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; | &lt;span class="sb"&gt;`components/site-a.tsx`&lt;/span&gt; | ✅ compliant |
| &lt;span class="nt"&gt;&amp;lt;Site&lt;/span&gt; &lt;span class="na"&gt;B&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; | &lt;span class="sb"&gt;`components/site-b.tsx`&lt;/span&gt; | ⏳ retrofit open |

&lt;span class="gu"&gt;## Cross-References&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;neighbor-rule.md&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;neighbor-rule.md&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; — boundary: what is governed there, not here.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule itself (the first two or three sentences under &lt;em&gt;The Rule&lt;/em&gt;) is the smallest part, and that is no accident. It states imperatively what applies. Everything beyond that belongs to the other four parts.&lt;/p&gt;

&lt;p&gt;The reasoning is not a courtesy to the reader. An agent weighs trade-offs, and as observed in the DI² project, a rule without a reason loses that weighing more easily against a plausible counterargument from the concrete case. A rule with a reason anchors what the concrete case has to argue against. The difference shows in exactly the moments that matter: namely, when the model considers an exception justified. How a convention and its reasoning come into being in the first place is described in the methodology article &lt;a href="https://sql.marcus-belz.de/en/deriving-sql-conventions-with-claude-code/" rel="noopener noreferrer"&gt;Deriving SQL Conventions with Claude Code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The remaining three parts are the actual substance of this article. The don'ts and the inventory each get their own section below, and the cross-references almost explain themselves: they draw the boundary to the neighboring rule so that two files do not creep into governing the same topic. Every boundary violation that surfaces later gets recorded there as an explicit reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Don'ts Beat Prescriptions
&lt;/h2&gt;

&lt;p&gt;This is the central observation from several months of rule maintenance in the DI² project. Whether it transfers to other projects has not been examined — it is project experience, not a study. A prescription says what should be. A negative example shows what must not be — with a concrete, recognizable spelling. The difference looks small at first glance. In practice it is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Before: a prescription without an anchor --&amp;gt;&lt;/span&gt;

Use only the project tokens for font sizes.

&lt;span class="c"&gt;&amp;lt;!-- After: negative examples with recognition value --&amp;gt;&lt;/span&gt;

&lt;span class="gs"&gt;**Don'ts:**&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; ❌ &lt;span class="sb"&gt;`text-[12px]`&lt;/span&gt; — raw pixel value, drifts; snap target is &lt;span class="sb"&gt;`text-token-meta`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; ❌ &lt;span class="sb"&gt;`text-sm`&lt;/span&gt; — framework default instead of the project scale; in the app
  scope the linter guard flags it as an error.
&lt;span class="p"&gt;-&lt;/span&gt; ❌ Inline &lt;span class="sb"&gt;`line-height`&lt;/span&gt; override via &lt;span class="sb"&gt;`[line-height:Xpx]`&lt;/span&gt; — the scale ships
  its own line height; a deliberate override needs a code comment with
  a justification.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prescription "use only the project tokens" is factually correct, and yet it accomplishes little. At every generation it competes against the model's training prior, in which &lt;code&gt;text-sm&lt;/code&gt; is the statistically most common way to write small text. "Only project tokens" first has to be translated onto the concrete case, and the rule gets lost in that translation.&lt;/p&gt;

&lt;p&gt;The negative example skips the translation. &lt;code&gt;text-[12px]&lt;/code&gt; is precisely the string the model is about to write — it sits verbatim in the rule, marked with a ❌ and the reason. A don't leaves no room for interpretation. It additionally names the snap target, the spelling that applies instead. Whoever reads the don't, whether human or model, afterwards knows both things: what is wrong and what belongs in its place. There is no don't-specific magic behind this, but a familiar effect from prompt research: concrete examples are easier for language models to act on than abstract prescriptions. Negative examples are simply the form in which a rule file can harness that effect.&lt;/p&gt;

&lt;p&gt;The best don'ts do not come from the rule author's imagination but from real finds. Every time a review or a bug surfaces a new bypass variant, exactly that variant goes into the rule file as a don't. The rule system thereby learns the same mistakes the code has already made once. There was no shortage of material: the sibling article &lt;a href="https://sql.marcus-belz.de/en/ai-code-drift-799-font-sizes/" rel="noopener noreferrer"&gt;AI-Assisted Coding Gave Me 799 Hardcoded Font Sizes&lt;/a&gt; documents the drift finding from which the font-size don'ts emerged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inventories as a Drift Radar
&lt;/h2&gt;

&lt;p&gt;A rule states a target state, and target states have an inconvenient property: they cannot become wrong. "Every dialog carries a leading icon" stays correct as a sentence even when six dialogs without icons have long been sitting in the code. The rule notices none of it.&lt;/p&gt;

&lt;p&gt;An inventory changes that. It lists the concrete usage sites of the convention together with their actual state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Inventory + Retrofit Backlog&lt;/span&gt;

As of now, this rule is the documented truth. Existing sites without
&lt;span class="nt"&gt;&amp;lt;convention&amp;gt;&lt;/span&gt; are brought in line as a tracked follow-up step (retrofit) —
until then the code deliberately lags behind the documentation.

| Dialog | File | Status |
|---|---|---|
| Editor (domain object A) | &lt;span class="sb"&gt;`components/object-a-editor-dialog.tsx`&lt;/span&gt; | ✅ icon leads left |
| Inspector (domain object B) | &lt;span class="sb"&gt;`components/object-b-inspector.tsx`&lt;/span&gt; | ✅ icon leads left |
| Invite user | &lt;span class="sb"&gt;`components/user-invite-dialog.tsx`&lt;/span&gt; | ⏳ retrofit open |
| Bulk delete | &lt;span class="sb"&gt;`components/bulk-delete-dialogs.tsx`&lt;/span&gt; | ⏳ retrofit open (destructive tone) |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table is the part of a rule file that can fail against the actual state of the code. A reasoning section or a cross-reference can go stale too, but only the inventory makes a checkable claim about what the code looks like right now. If a new dialog arrives and is missing from the table, the inventory is incomplete. If a listed dialog gets rebuilt and its status is not updated, the inventory is stale. Exactly this vulnerability is what makes it valuable: a rule without an inventory can exist past the code unnoticed for years, while the lag of a rule with an inventory becomes visible at the next reconciliation, at the latest. The inventory is the rule's drift radar.&lt;/p&gt;

&lt;p&gt;In the project, 9 of the 27 rule files carry such sections. They come in two flavors. The &lt;strong&gt;caller inventory&lt;/strong&gt; lists who uses a component or convention — it answers the question "if I change this, what is affected?" before anyone has to search. The &lt;strong&gt;retrofit backlog&lt;/strong&gt; lists which existing sites do not yet satisfy the convention. Both forms share the mechanics but differ in the direction of view: one looks at the rule's users, the other at its open debts.&lt;/p&gt;

&lt;p&gt;The inventory is not maintained in a separate documentation session but in the same commit as the code change. Whoever adds a dialog adds it to the table. Whoever completes a retrofit sets the status to ✅. That sounds like a high demand on discipline. In an agent workflow, though, it is the cheapest possible moment, because the agent usually has the rule file in context anyway when it works inside the rule's scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules That Load Only Where They Apply
&lt;/h2&gt;

&lt;p&gt;As the file count grows, the context-cost question returns. 27 files that all load all the time would just be a partitioned CLAUDE.md at the same cost. The lever against that is a &lt;code&gt;paths:&lt;/code&gt; front matter that binds a rule to its path scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;paths&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;src/app/api/**"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/lib/db*"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Backend conventions&lt;/span&gt;

This rule loads only when the task touches files in its path scope —
API routes and the database access layer. A frontend task does not
pay its context costs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the project, 3 of the 27 files carry this front matter: the backend rules, the frontend rules and the security model. The selection follows a simple criterion. These three files are large, and their scope is a clearly bounded subtree of the project. A backend rule inside a pure frontend task is dead context. The remaining 24 files load unscoped because they are either small or apply across the whole project, like the commit conventions.&lt;/p&gt;

&lt;p&gt;This article deliberately goes no deeper here. The mechanics behind it — what Claude Code loads into context when, what separates rules from skills, and how the costs add up — are the subject of &lt;a href="https://sql.marcus-belz.de/en/claude-code-skills-vs-rules/" rel="noopener noreferrer"&gt;Skills vs. Rules in Claude Code&lt;/a&gt;. For the structural question of this article, the finding suffices: &lt;code&gt;paths:&lt;/code&gt; scoping is the main reason 27 files do not mean 27-fold costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Downside
&lt;/h2&gt;

&lt;p&gt;A rule system of this size is not free, and an experience report that hides that would be advertising.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maintenance is real work.&lt;/strong&gt; Every rule file wants attention at every convention change, inventories want updating in the same commit, and the cross-references between files go stale when a rule moves. The effort is not a one-time investment but a running cost. In the project it is the kind of work that pays off, but it does not disappear just because you approve of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rules end up in conflict.&lt;/strong&gt; With 27 files it happens that two rules govern the same case from different angles — the dialog rule wants an icon, the confirmation-dialog rule forbids one for its special case. The resolution is the same every time: the conflict gets written into both files as an explicit carve-out, with references to each other. Undecided conflicts are the worst thing that can happen to a rule system, because then the prioritization stays ambiguous — which rule prevails depends on the particular context and the task at hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The documentation runs ahead of the code — deliberately.&lt;/strong&gt; When a new convention is decided, it applies to new code immediately. The existing sites are not rebuilt within the hour but tracked as a retrofit backlog in the inventory and brought in line step by step. That state is not a failure as long as it is documented. The rule file says honestly: this is the truth, and the code deliberately lags behind at these listed sites. Untracked, the same state would be a lie, because then the documentation claims an actual state that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a Rule Needs a Linter
&lt;/h2&gt;

&lt;p&gt;Prose has a limit, and the sibling article &lt;a href="https://sql.marcus-belz.de/en/ai-code-drift-799-font-sizes/" rel="noopener noreferrer"&gt;AI-Assisted Coding Gave Me 799 Hardcoded Font Sizes&lt;/a&gt; has measured it out in detail: a documented rule raises the generation hit rate but does not guarantee it. At high volume, any residual rate turns into visible drift.&lt;/p&gt;

&lt;p&gt;From that follows a division of labor that has proven itself in the project. Whatever is machine-checkable gets a guard in addition to the rule — in the font-size case a custom ESLint rule at error level that flags exactly the spellings from the don'ts. The rule file remains the source all the same: it explains the why, defines the mapping and lists the deliberate exceptions, while the linter enforces only the checkable subset.&lt;/p&gt;

&lt;p&gt;Whatever is not machine-checkable stays a pure prose rule and needs the inventory as its substitute radar. No linter can decide whether a dialog icon is the right one for the domain. Whether all dialogs have one is written in the inventory table. The rule of thumb from the project: a rule whose violation can be expressed as a search pattern is a linter candidate. A rule whose violation a human has to recognize needs an inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Do Differently Today
&lt;/h2&gt;

&lt;p&gt;Looking back, I would approach three things differently, and all three can be named concretely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Too many files too early.&lt;/strong&gt; The first weeks produced rule files for topics that did not even have a second usage site yet. A convention for a single case is not a convention but a note. Today a new rule file comes into being only when the same decision comes up for the second time. And cleanup is part of the deal: a rule whose scope has disappeared, or whose content a neighboring rule meanwhile carries, gets deleted or merged into that neighbor. A rule set that only ever grows becomes the very unwieldy file it was meant to replace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rules without inventories that went stale silently.&lt;/strong&gt; The early files consisted of rule and reasoning, without a state-of-the-code part. After two months, some of them described a state the code had long left behind, and nobody had noticed. Only the inventory sections made the aging visible. In hindsight, every rule with usage sites should have carried one from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discovered &lt;code&gt;paths:&lt;/code&gt; too late.&lt;/strong&gt; The scoping arrived only when the context costs were already noticeable, and converting existing files to clean path scopes was more tedious than an early cut would have been. Anyone starting today should ask one short question at every new rule file: does this apply everywhere, or in one subtree? Answering that question costs ten seconds at creation time. Not asking it costs a refactor later.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;When should I split my CLAUDE.md?&lt;/strong&gt;&lt;br&gt;
At the latest when, while writing a new convention into it, the question comes up where it actually belongs. That is the signal that the file carries multiple topics. A second signal is the repeated ignoring of a rule that verifiably sits in the file — the rule is drowning in its surroundings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many rule files are too many?&lt;/strong&gt;&lt;br&gt;
The number itself is not the limit, the context costs are. 27 files work because the large ones load path-scoped and the unscoped ones are small. How those costs add up and where the line runs is covered by &lt;a href="https://sql.marcus-belz.de/en/claude-code-skills-vs-rules/" rel="noopener noreferrer"&gt;Skills vs. Rules in Claude Code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do negative examples work better than prescriptions?&lt;/strong&gt;&lt;br&gt;
Because they close the translation gap. A prescription first has to be applied by the model onto the concrete case, while a don't already contains the wrong spelling verbatim — precisely the string the training prior would suggest, marked with the reason and the target spelling. Recognizing is more reliable than deriving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do I do when two rules contradict each other?&lt;/strong&gt;&lt;br&gt;
Write the conflict into both files, as an explicit carve-out with references to each other. Which rule wins in the overlap case must be stated in the files, not in the author's head. An undecided conflict otherwise gets decided by chance, depending on which rule happens to sit more prominently in context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the code always have to match the documentation?&lt;/strong&gt;&lt;br&gt;
No — and that may be the most counterintuitive lesson. A new convention applies to new code immediately, while the existing code is brought in line step by step as a tracked retrofit backlog. The tracking is what matters: a documented lag is a deliberate decision, an undocumented one is documentation that lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Going deeper:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/claude-code-skills-vs-rules/" rel="noopener noreferrer"&gt;Skills vs. Rules in Claude Code — What Auto-Loads, What Loads on Demand&lt;/a&gt; — the loading mechanics and context costs behind this article.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/claude-code-project-template-self-pruning/" rel="noopener noreferrer"&gt;Maximal Template Over Empty Repo — a Claude Code Setup That Prunes Itself via /init&lt;/a&gt; — how a rule inventory gets tailored at project start.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/claude-code-project-with-database-setup/" rel="noopener noreferrer"&gt;Setting Up a Claude Code Project with a Development Workflow and Database&lt;/a&gt; — the sub-pillar: the setup this rule system lives in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Upstream:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/ai-code-drift-799-font-sizes/" rel="noopener noreferrer"&gt;AI-Assisted Coding Gave Me 799 Hardcoded Font Sizes&lt;/a&gt; — the drift finding that makes rules and guards necessary in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/deriving-sql-conventions-with-claude-code/" rel="noopener noreferrer"&gt;Deriving SQL Conventions with Claude Code — the Generate-Refine-Derive Loop&lt;/a&gt; — how a convention comes into being before you pour it into a rule file.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/ai-assisted-sql-development-with-claude-code/" rel="noopener noreferrer"&gt;AI-Assisted SQL Development with Claude Code — Rules, Skills and Agents&lt;/a&gt; — the pillar: the enforcement system as a whole.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Starter kit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/marcusbelz/di2-starter-kit" rel="noopener noreferrer"&gt;The open DI² starter kit on GitHub&lt;/a&gt; — a project template built on this article's one-file-per-convention principle, ready to tailor.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>AI-Assisted Coding Gave Me 799 Hardcoded Font Sizes</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Tue, 21 Jul 2026 23:42:38 +0000</pubDate>
      <link>https://dev.to/marcus1968/ai-assisted-coding-gave-me-799-hardcoded-font-sizes-27fn</link>
      <guid>https://dev.to/marcus1968/ai-assisted-coding-gave-me-799-hardcoded-font-sizes-27fn</guid>
      <description>&lt;p&gt;It didn't start with an audit. It started with a nagging feeling: the interface looked restless. You don't notice it at first glance, but on the second and third look it is there — a timestamp slightly larger here than there, a dialog title a touch smaller than in the neighboring dialog. Everyone knows the discipline from letters and résumés: same typeface, same font size, same alignment. The same holds for an application interface, except the violation doesn't show up in any one spot. It shows up as a diffuse restlessness across many screens.&lt;/p&gt;

&lt;p&gt;Only that feeling led to a counting command across the frontend, and the count delivered the explanation: 799 hits for raw pixel font sizes like &lt;code&gt;text-[13px]&lt;/code&gt;, spread across 25 distinct pixel values in 74 files. And that in a project which had a documented font-size scale with six tokens all along. The scale was used 263 times and bypassed roughly 1,180 times. This is not a sloppiness finding from some legacy codebase grown over a decade — it is the state of a codebase built AI-assisted with Claude Code from day one. This is what &lt;strong&gt;AI code drift&lt;/strong&gt; looks like: every single suggestion is locally plausible, and what adds up is the restlessness you can see before you can measure it.&lt;/p&gt;

&lt;p&gt;This article is the experience report — with the real numbers, the rule that ended the problem, and the honest admission that a convention living only in prose loses against a language model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key points up front:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The finding:&lt;/strong&gt; 799 raw pixel font sizes against 263 token usages, even though the scale was documented. The ratio is the message, not the single number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The biggest source of drift lies between the tokens:&lt;/strong&gt; values like &lt;code&gt;11px&lt;/code&gt; and &lt;code&gt;12px&lt;/code&gt;, for which no token existed at all, form the majority at roughly 400 occurrences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No code review catches this&lt;/strong&gt;, because every diff is harmless on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better prompts raise the hit rate but don't eliminate the drift&lt;/strong&gt; — at high generation volume, any residual rate becomes visible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What holds:&lt;/strong&gt; a 6-step scale, a fixed element-to-token mapping, an unambiguous snap rule for all in-between values, and a linter at &lt;code&gt;error&lt;/code&gt; level.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt; The example uses Tailwind CSS and ESLint in a Next.js project. The pattern applies to any codebase with design tokens, regardless of framework — and, as the end of the article shows, just as much to SQL conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Finding: 799 Against 263
&lt;/h2&gt;

&lt;p&gt;The project behind the numbers is DI², an ETL generator built on Next.js and PostgreSQL whose code was written almost entirely AI-assisted. For font sizes, a clear convention existed: six named tokens from &lt;code&gt;text-di-h1&lt;/code&gt; (18px) down to &lt;code&gt;text-di-label&lt;/code&gt; (10px), defined in the Tailwind configuration and described in a brand rule file that the agent loads for every frontend task. Everything that follows is therefore the measurement of a single project — one data point, not proof. What makes the case interesting beyond the project is the mechanism behind it, and that mechanism, as we will see, is not project-specific.&lt;/p&gt;

&lt;p&gt;The inventory on June 25, 2026 across all &lt;code&gt;src/**/*.tsx&lt;/code&gt; files produced three categories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Declaration style&lt;/th&gt;
&lt;th&gt;Occurrences&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Assessment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw pixels &lt;code&gt;text-[Xpx]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;799&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;drift, 25 distinct pixel values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tailwind defaults &lt;code&gt;text-xs&lt;/code&gt;/&lt;code&gt;text-sm&lt;/code&gt;/&lt;code&gt;text-base&lt;/code&gt; …&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;383&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;drift within the app scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical tokens &lt;code&gt;text-di-*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;the target pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The three rows distinguish three ways of declaring the same font size. &lt;code&gt;text-[13px]&lt;/code&gt; is Tailwind's arbitrary-value syntax: the pixel value sits literally inside square brackets and acts like an inline &lt;code&gt;font-size&lt;/code&gt; — any value is possible, and that is how 25 different ones come into being. &lt;code&gt;text-xs&lt;/code&gt;, &lt;code&gt;text-sm&lt;/code&gt; and &lt;code&gt;text-base&lt;/code&gt;, on the other hand, are named size steps, but they belong to the framework's bundled default scale (12, 14 and 16 pixels). That looks disciplined, because it follows a scale. It is just the wrong one: Tailwind's generic scale instead of the project's own, whose six tokens don't even contain those three values. The third row, finally, is the project's own scale — the target pattern. Read top to bottom, the table is a ladder: freehand value, foreign scale, own scale.&lt;/p&gt;

&lt;p&gt;That two scales coexist is not an accident, by the way — it is the framework's default. Tailwind ships its complete size ladder with every project, and the project's own tokens were added via &lt;code&gt;theme.extend&lt;/code&gt; — and &lt;code&gt;extend&lt;/code&gt; means exactly that: extend, not replace. From that point on, every &lt;code&gt;text-sm&lt;/code&gt; compiles just as happily as every &lt;code&gt;text-di-body&lt;/code&gt;. There is no moment at which the project consents to the foreign scale, and none at which it announces itself. It is simply there, from day one, as the statistically closest choice for anyone who needs small text — human or model.&lt;/p&gt;

&lt;p&gt;A second term needs an explanation, because it recurs throughout the article. The &lt;strong&gt;app scope&lt;/strong&gt; is the application behind the login: the dashboard and administration pages plus the components they render. The token scale applies only there. Outside it lie two zones with rights of their own — the public pages (landing, legal, login), which deliberately carry larger formats, and the bundled UI base components, which internally work with Tailwind defaults. A &lt;code&gt;text-sm&lt;/code&gt; is therefore not wrong per se; on a marketing page it is legitimate. That is why the second row says drift &lt;strong&gt;within the app scope&lt;/strong&gt;: of the 383 occurrences, only the share inside the application counts as drift.&lt;/p&gt;

&lt;p&gt;The convention, then, was ignored at a ratio of roughly 1:4.5. To rerun the inventory in your own project, all you need is a search tool like &lt;code&gt;ripgrep&lt;/code&gt; (the examples are written in PowerShell):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Raw pixel font sizes: total number of occurrences&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;rg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--no-filename&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'text-\[[0-9.]+px\]'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Measure-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Line&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Distribution: which pixel value occurs how often?&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;rg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--no-filename&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'text-\[[0-9.]+px\]'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Group-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Sort-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Descending&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# For comparison: the canonical tokens&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;rg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--no-filename&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'text-di-(h1|h2|body|meta|micro|label)'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Measure-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Line&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeline delivers a punchline of its own. Between the first count and the recount three weeks later, shortly before the cleanup migration started, the total grew from 799 to 813 occurrences and from 25 to 26 distinct values. The drift kept growing while its removal was already being planned. A convention that is not enforced doesn't lose once — it loses a little more every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Nobody Caught It
&lt;/h2&gt;

&lt;p&gt;The first reflex at a number like this: how did that slip through? The answer is uncomfortable because it describes no negligence, but a structural gap.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;text-[12px]&lt;/code&gt; is not wrong in any single diff. It renders correctly, it looks fine in the preview, it breaks no test. A reviewer reading the diff of a new dialog checks the logic, the states, the accessibility. They do not compare whether the font size in this file is consistent with the one in 73 other files. The drift doesn't live in any one file. It lives &lt;strong&gt;between&lt;/strong&gt; the files.&lt;/p&gt;

&lt;p&gt;It was visible all the same — just not as a defect, but as the nagging feeling from the beginning. A restless interface shows the symptom, not the location: which of the 74 files do you point at when no single line is wrong on its own? So the impression stayed without consequence for a long time. It couldn't be pinned to any diff, and what can't be pinned to a diff ends up in no review comment and no ticket. Visual regression tests usually don't catch this form of inconsistency either, because they compare each view against its own baseline. Two views that are both brand new share no baseline against which the difference could stand out. Only the inventory turned the feeling into a finding with numbers — and thereby into something fixable.&lt;/p&gt;

&lt;p&gt;Then there is the speed. A person who builds one dialog a day makes a handful of font-size decisions per week, and muscle memory keeps them reasonably stable. An agent that creates twenty components in the same week makes the same decision a hundred times over — and each one is optimized locally, not for consistency with all the previous ones. The inconsistency doesn't come from carelessness; it comes from the sheer volume of independent single decisions. The densest single file in the inventory accounted for 70 raw pixel sizes on its own.&lt;/p&gt;

&lt;p&gt;Humans produce the same drift — honesty demands saying so. The difference is not the kind of mistake but the pace and the volume. What a team accumulates in two years of wild growth, AI-assisted development manages in a quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Drift Lives Between the Tokens
&lt;/h2&gt;

&lt;p&gt;The most revealing part of the inventory is the distribution of the 25 pixel values. It splits into three classes, and the middle one is the interesting one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exact token matches:&lt;/strong&gt; values that correspond to a token, just written raw — &lt;code&gt;13px&lt;/code&gt; instead of &lt;code&gt;text-di-body&lt;/code&gt; (166 occurrences), &lt;code&gt;11.5px&lt;/code&gt; instead of &lt;code&gt;text-di-meta&lt;/code&gt; (46), plus the remaining token values. This class is mechanically repairable and visually a no-op.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Off-token values:&lt;/strong&gt; roughly 400 occurrences on values for which &lt;strong&gt;no token exists at all&lt;/strong&gt; — led by &lt;code&gt;11px&lt;/code&gt; with 196 and &lt;code&gt;12px&lt;/code&gt; with 143 occurrences, plus &lt;code&gt;12.5px&lt;/code&gt; (42) and &lt;code&gt;14px&lt;/code&gt; (19).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliberate exceptions:&lt;/strong&gt; marketing and legal pages with large formats of their own, such as &lt;code&gt;22px&lt;/code&gt; or &lt;code&gt;44px&lt;/code&gt;, which intentionally sit outside the app scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second class deserves the second look. &lt;code&gt;11px&lt;/code&gt; and &lt;code&gt;12px&lt;/code&gt; both sit next to the same token, &lt;code&gt;text-di-meta&lt;/code&gt; (11.5px), and both were later migrated to it. Together that is 339 places where two different pixel values played the same semantic role — meta text, timestamps, helper lines. Nobody ever decided that both values should exist. There is no commit with the message "we are introducing 11px as an alternative to 12px." Both values simply came into being, suggestion by suggestion, because each looked reasonable on its own.&lt;/p&gt;

&lt;p&gt;That is what separates this drift from a copying error. Someone who mistypes a documented value produces a findable defect. Someone who puts plausible values into a gap of the scale a hundred times over produces a creeping second scale that was never decided anywhere and therefore never stands out anywhere. That is AI code drift in its purest form.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Better Prompts" Fallacy
&lt;/h2&gt;

&lt;p&gt;The obvious reaction to the finding would have been to state the convention more forcefully. Add a chapter to the brand rule, repeat the tokens in the prompt, instruct the agent more insistently.&lt;/p&gt;

&lt;p&gt;That doesn't carry far, and the reason lies in how the model works. A language model is trained on vast amounts of public code, among it countless Tailwind projects, and there &lt;code&gt;text-sm&lt;/code&gt; or &lt;code&gt;text-[12px]&lt;/code&gt; is the overwhelmingly most common way to write small text. A project-specific convention like &lt;code&gt;text-di-meta&lt;/code&gt;, by contrast, is exactly one file in a rules directory. At every single generation the rule competes against that weight, and it wins often — but not always. Across a thousand decisions, a hit rate of 90 percent still leaves a hundred drift spots, and even a considerably higher rate ends up in the dozens at sufficient volume.&lt;/p&gt;

&lt;p&gt;Rules in prose improve the rate, and rules with reasons improve it further. How a ruleset comes into being that an agent actually follows is described in the methodology article &lt;a href="https://sql.marcus-belz.de/en/deriving-sql-conventions-with-claude-code/" rel="noopener noreferrer"&gt;Deriving SQL Conventions with Claude Code&lt;/a&gt;. But any rate below 100 percent means drift at high volume. For a convention that is meant to hold without exception, the prompt is the wrong tool. It needs a check that doesn't get tired.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Works: Scale, Category Mapping, Snap Rule
&lt;/h2&gt;

&lt;p&gt;The cleanup consisted of three building blocks that only work in combination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the scale itself.&lt;/strong&gt; Six steps, defined in exactly one place in the Tailwind configuration, each with its line height built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tailwind.config.ts — the scale as the single source of truth&lt;/span&gt;
&lt;span class="nx"&gt;fontSize&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;di-h1&lt;/span&gt;&lt;span class="dl"&gt;'&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;18px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;23.4px&lt;/span&gt;&lt;span class="dl"&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;di-h2&lt;/span&gt;&lt;span class="dl"&gt;'&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;15px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;21px&lt;/span&gt;&lt;span class="dl"&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;di-body&lt;/span&gt;&lt;span class="dl"&gt;'&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;13px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;18.85px&lt;/span&gt;&lt;span class="dl"&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;di-meta&lt;/span&gt;&lt;span class="dl"&gt;'&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;11.5px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;15px&lt;/span&gt;&lt;span class="dl"&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;di-micro&lt;/span&gt;&lt;span class="dl"&gt;'&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;10.5px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;13.65px&lt;/span&gt;&lt;span class="dl"&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;di-label&lt;/span&gt;&lt;span class="dl"&gt;'&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;10px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;12px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;letterSpacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.05em&lt;/span&gt;&lt;span class="dl"&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;An important decision was to keep the scale at six steps. The tempting alternative would have been to create new tokens for &lt;code&gt;11px&lt;/code&gt; and &lt;code&gt;12px&lt;/code&gt; and thereby legalize the status quo. That would have turned the drift into an official eight-step scale, and the next in-between value would have found a gap again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the element-category mapping.&lt;/strong&gt; A table in the rule file defines which kind of element carries which token. Section headings get &lt;code&gt;di-h1&lt;/code&gt;, dialog titles &lt;code&gt;di-h2&lt;/code&gt;, table cells and buttons &lt;code&gt;di-body&lt;/code&gt;, timestamps and helper texts &lt;code&gt;di-meta&lt;/code&gt;, counter pills &lt;code&gt;di-micro&lt;/code&gt;, uppercase labels &lt;code&gt;di-label&lt;/code&gt;. With that, "what size does this element need?" is no longer a matter of taste but a lookup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the snap rule.&lt;/strong&gt; The name says it: roughly 400 occurrences sat on in-between values like &lt;code&gt;11px&lt;/code&gt; or &lt;code&gt;12px&lt;/code&gt;, and during the migration each of them had to snap onto one of the six tokens — like an object being pulled onto the grid in a graphics editor. Which token it becomes is decided in two stages. First comes the element's role from the category mapping: a timestamp gets &lt;code&gt;di-meta&lt;/code&gt; because it is meta text, no matter which pixel value used to be there. Only when an occurrence cannot be assigned to any category does numeric proximity to the nearest token decide. Even the single tie is settled explicitly: &lt;code&gt;14px&lt;/code&gt; sits exactly between &lt;code&gt;di-body&lt;/code&gt; (13px) and &lt;code&gt;di-h2&lt;/code&gt; (15px), and &lt;code&gt;di-body&lt;/code&gt; wins as the default. Role before number is not a formality, either. A &lt;code&gt;12px&lt;/code&gt; in a sticky table header belongs to &lt;code&gt;di-micro&lt;/code&gt; (10.5px) by category, although &lt;code&gt;di-meta&lt;/code&gt; (11.5px) would be numerically closer. And because the rule leaves no room for judgment, two people — or two agent runs — resolve the same raw value identically, guaranteed.&lt;/p&gt;

&lt;p&gt;With those three building blocks the migration itself was unspectacular: 80 files in the app scope, converted category by category, with shifts of less than a pixel. Exactly one deliberate exception remained — a large page title outside the app scale, marked with an inline comment and a justification.&lt;/p&gt;

&lt;p&gt;One side finding from the same cleanup deserves a mention for completeness: the tokens had to be explicitly registered as font-size classes in the &lt;code&gt;tailwind-merge&lt;/code&gt; configuration, because the library otherwise classifies unknown &lt;code&gt;text-*&lt;/code&gt; classes as text color and silently discards one of them on conflict. That, however, is an ordinary trap when introducing custom tokens and has nothing to do with AI. Know it, pin it with a small test, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even the Fresh Rule Drifted
&lt;/h2&gt;

&lt;p&gt;The most instructive finding of the whole story comes from the migration's quality assurance, and it cuts against the author.&lt;/p&gt;

&lt;p&gt;The category rule — "the element category wins over pixel proximity" — was written into the rule file in the same commit that carried the migration. And in that very commit it was undercut: the data cells of the densest table registers, previously &lt;code&gt;12px&lt;/code&gt;, belonged to &lt;code&gt;di-body&lt;/code&gt; (13px) according to the category table. They were migrated numerically to &lt;code&gt;di-meta&lt;/code&gt; (11.5px) instead, because that matched the density intent of those views. The freshly written rule and its first application contradicted each other, and it surfaced only in the downstream QA pass, which filed it as a documentation inconsistency.&lt;/p&gt;

&lt;p&gt;You can consider the finding small — half a pixel in dense tables. Its value lies elsewhere: it shows that even a carefully worded, freshly printed rule drifts at the moment of its application when only humans and prose carry it. Not out of ignorance, but because a second legitimate consideration intervened in the concrete case and nobody checked against the wording. This rule-based check is one of the tasks a machine performs more reliably than any participant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforcement: A Linter at error
&lt;/h2&gt;

&lt;p&gt;The fourth building block therefore makes the convention machine-checkable. The tool class is secondary: a compiler check, a Tailwind plugin or a CI script can play the same role. For this project, a custom ESLint rule was the most practical form — it reports every raw pixel font size and every Tailwind size default in the app scope as an &lt;strong&gt;error&lt;/strong&gt;, not a warning. The core of the rule fits on a page:&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="c1"&gt;// eslint-rules/no-raw-font-size.mjs — the core of the rule&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PX_RE&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/text-&lt;/span&gt;&lt;span class="se"&gt;\[(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(?:\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?)&lt;/span&gt;&lt;span class="sr"&gt;px&lt;/span&gt;&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;!&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;text-&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;xs|sm|base&lt;/span&gt;&lt;span class="se"&gt;)(?![\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;

&lt;span class="c1"&gt;// Snap table: raw value -&amp;gt; canonical token (from the rule file)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PX_SNAP&lt;/span&gt; &lt;span class="o"&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;11&lt;/span&gt;&lt;span class="dl"&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;text-di-meta&lt;/span&gt;&lt;span class="dl"&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;12&lt;/span&gt;&lt;span class="dl"&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;text-di-meta&lt;/span&gt;&lt;span class="dl"&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;12.5&lt;/span&gt;&lt;span class="dl"&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;text-di-body&lt;/span&gt;&lt;span class="dl"&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;13&lt;/span&gt;&lt;span class="dl"&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;text-di-body&lt;/span&gt;&lt;span class="dl"&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;14&lt;/span&gt;&lt;span class="dl"&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;text-di-body&lt;/span&gt;&lt;span class="dl"&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;15&lt;/span&gt;&lt;span class="dl"&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;text-di-h2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;problem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;rawPx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Raw pixel font size `{{match}}`. Use {{target}}.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&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="nx"&gt;context&lt;/span&gt;&lt;span class="p"&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;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for &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;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PX_RE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rawPx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PX_SNAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a token from the scale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;// DEFAULT_RE analogous&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="nc"&gt;TemplateElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cooked&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&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;Three decisions have proven themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rule inspects all string literals and template parts&lt;/strong&gt;, not just JSX attributes (the &lt;code&gt;Literal&lt;/code&gt; and &lt;code&gt;TemplateElement&lt;/code&gt; visitors at the end of the rule). In practice, class strings also arise in &lt;code&gt;cn()&lt;/code&gt; arguments, in &lt;code&gt;.join(" ")&lt;/code&gt; helpers and in exported constants — a rule that only sees &lt;code&gt;className="…"&lt;/code&gt; would have blind spots there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The error message names the snap target&lt;/strong&gt; (the &lt;code&gt;target&lt;/code&gt; field in the report data). Whoever sees the error sees the fix and doesn't have to go find the rule file first. That holds for human readers just as much as for the agent reacting to the linter error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;error&lt;/code&gt;, not &lt;code&gt;warn&lt;/code&gt;.&lt;/strong&gt; A warning is a number in a summary; an error breaks the build. Only the second one is enforcement. The halfway path — warnings plus occasional cleanup — ends up reproducing the very state that led to the drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One obvious alternative deserves a mention because it looks simpler than it is: remove Tailwind's default scale from the configuration altogether by defining &lt;code&gt;fontSize&lt;/code&gt; without &lt;code&gt;extend&lt;/code&gt;. Then &lt;code&gt;text-sm&lt;/code&gt; simply would not exist anymore. That fails on two counts. An unknown &lt;code&gt;text-sm&lt;/code&gt; produces no error in Tailwind — it produces no CSS at all, so the text would silently fall back to the browser's default size, and a silent failure like that is harder to find than the drift it is meant to prevent. And the bundled UI base components, like the marketing pages, build on exactly those default classes internally. The radical fix would break the very zones that legitimately live off the standard scale. A linter with a scope can express that; a global configuration cannot.&lt;/p&gt;

&lt;p&gt;The deliberate exceptions therefore live not in the rule but in the ESLint configuration: a path list exempts marketing, legal and login pages, whose large formats intentionally sit outside the app scale. That records the convention's scope machine-readably in one place, congruent with the scope in the rule file. The direction of the definition is worth noting: the app scope itself is never listed anywhere — it is simply everything that was not exempted. A new public page that nobody adds to the exemption list is treated as app scope, and the guard flags its large formats as errors. That is the right direction to fail: a forgotten list entry shows up as a loud false alarm in the build instead of slipping through as silent drift.&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="c1"&gt;// eslint.config.mjs — carve-out as a path list (abridged)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FONT_SIZE_CARVE_OUT&lt;/span&gt; &lt;span class="o"&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;src/app/page.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// landing: deliberately larger formats&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/app/impressum/**&lt;/span&gt;&lt;span class="dl"&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;src/app/login/**&lt;/span&gt;&lt;span class="dl"&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;src/**/*.test.{ts,tsx}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// tests reference classes as test data&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;files&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;src/**/*.{ts,tsx}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;rules&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;di2/no-raw-font-size&lt;/span&gt;&lt;span class="dl"&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;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FONT_SIZE_CARVE_OUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;rules&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;di2/no-raw-font-size&lt;/span&gt;&lt;span class="dl"&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;off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&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;One sequencing detail to close: the guard was activated as the &lt;strong&gt;last&lt;/strong&gt; step, after the app scope had been migrated clean. The other way around, every unfinished file would have needed a temporary exemption list, and temporary exemption lists have a tendency to become permanent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Linter Cannot Do
&lt;/h2&gt;

&lt;p&gt;For the experience report to stay honest, the limits belong in it.&lt;/p&gt;

&lt;p&gt;The guard checks the &lt;strong&gt;spelling&lt;/strong&gt;, not the &lt;strong&gt;assignment&lt;/strong&gt;. That an element carries a token at all — that it enforces reliably. Whether it is the right token for the element's category it cannot know — in that gap lived the rule contradiction from the QA finding, and there it can arise again. The category assignment remains a rule in prose, with all the weaknesses described, just on a much smaller attack surface.&lt;/p&gt;

&lt;p&gt;Two limits are chosen deliberately. Larger Tailwind defaults from &lt;code&gt;text-lg&lt;/code&gt; upward the rule does not flag, because a strikingly large text in an app file is a case for design review, not a candidate for mechanical snapping. And dynamically assembled class strings, such as values concatenated from variables, are only partially covered by a static rule. Both gaps are documented rather than concealed, because a check whose omissions nobody knows creates false confidence.&lt;/p&gt;

&lt;p&gt;And finally: the linter preserves the scale, it does not justify it. Whether six steps are the right six and which category deserves which token remains a design decision that precedes the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Pattern in SQL
&lt;/h2&gt;

&lt;p&gt;The case is a frontend case on purpose, because that is where the drift was measurable. The pattern behind it is bound to no framework. Transferring it to SQL is therefore a reasoned analogy, not a second measurement — but one that follows from the same mechanism.&lt;/p&gt;

&lt;p&gt;A SQL convention — singular table names, snake_case parameters, a fixed procedure skeleton — is the same class of rule as a font-size scale: a project-specific commitment competing against the statistically more common spelling from other codebases. An agent generating PL/pgSQL drifts there for the same reason as with the pixel values, and the answer has the same structure. The convention lives versioned in a rule file, with reasons and negative examples, the way the &lt;a href="https://sql.marcus-belz.de/en/postgres-table-conventions/" rel="noopener noreferrer"&gt;Postgres Table Conventions&lt;/a&gt;, the &lt;a href="https://sql.marcus-belz.de/en/plpgsql-procedure-conventions/" rel="noopener noreferrer"&gt;PL/pgSQL Procedure Conventions&lt;/a&gt; and the &lt;a href="https://sql.marcus-belz.de/en/plpgsql-function-conventions/" rel="noopener noreferrer"&gt;PL/pgSQL Function Conventions&lt;/a&gt; demonstrate. And what is machine-checkable gets checked by a tool: for SQL layout, say, &lt;code&gt;sqlfluff&lt;/code&gt; with a project-specific configuration; for structural conventions, a script in the CI gate.&lt;/p&gt;

&lt;p&gt;The division of labor is the same in both worlds. The prose rule explains the why and raises the hit rate. The tool closes the gap between a high hit rate and zero exceptions. Because the underlying pattern stays the same, whether in the frontend or in the database: local plausibility creates global inconsistency. How the overall interplay of rules, skills and agents is set up is described in the pillar article &lt;a href="https://sql.marcus-belz.de/en/ai-assisted-sql-development-with-claude-code/" rel="noopener noreferrer"&gt;AI-Assisted SQL Development with Claude Code&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How do I find consistency drift in my own project?&lt;/strong&gt;&lt;br&gt;
It often announces itself as an impression first: the interface feels restless without any single spot to point at. It becomes tangible through a counting inventory: one search pattern for the canonical spelling, one for the bypasses, and the two numbers set in relation. For design tokens those are expressions like &lt;code&gt;text-\[[0-9.]+px\]&lt;/code&gt; against the token names. More revealing than the total is the distribution of values — clusters on values without a token show where the scale has a gap or where an unofficial second convention has formed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isn't it enough to put the rule into CLAUDE.md?&lt;/strong&gt;&lt;br&gt;
A documented rule improves the hit rate but doesn't guarantee it — at high generation volume, any residual rate turns into measurable drift. Rules in prose and machine checks are therefore not alternatives but two halves: the rule explains the why, the linter enforces the what. Where rules live in Claude Code and what they cost in context is covered in &lt;a href="https://sql.marcus-belz.de/en/claude-code-skills-vs-rules/" rel="noopener noreferrer"&gt;Skills vs. Rules in Claude Code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a custom ESLint rule instead of an off-the-shelf one?&lt;/strong&gt;&lt;br&gt;
Because the error message should name the project's own snap target, and the check has to cover class strings outside JSX attributes as well. Generic approaches like &lt;code&gt;no-restricted-syntax&lt;/code&gt; can flag the pattern but cannot suggest a contextual fix — and it is exactly that fix which makes the error immediately actionable for humans and agents alike.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this only apply to CSS and design tokens?&lt;/strong&gt;&lt;br&gt;
No. AI code drift affects any project-specific convention that competes against a more widespread standard spelling — SQL naming, file structures, error-handling patterns all drift by the same mechanism. The countermeasure is the same everywhere: document with reasons, and pour the machine-checkable part into a tool at error level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doesn't a human team produce the same drift?&lt;/strong&gt;&lt;br&gt;
It does, and that is the honest core. The difference is pace: an agent makes as many individual decisions in a week as a team makes in months, and it makes each one optimized locally rather than for consistency with the previous ones. Conventions that held up passably at human speed break visibly under that volume — which also means AI drift merely makes an old problem visible faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Going deeper:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/deriving-sql-conventions-with-claude-code/" rel="noopener noreferrer"&gt;Deriving SQL Conventions with Claude Code — the Generate-Refine-Derive Loop&lt;/a&gt; — how a convention comes into being before you can enforce it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/ai-assisted-sql-development-with-claude-code/" rel="noopener noreferrer"&gt;AI-Assisted SQL Development with Claude Code — Rules, Skills and Agents&lt;/a&gt; — the pillar: the enforcement system as a whole.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/claude-code-skills-vs-rules/" rel="noopener noreferrer"&gt;Skills vs. Rules in Claude Code&lt;/a&gt; — where conventions live and what they cost in context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Convention spokes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/postgres-table-conventions/" rel="noopener noreferrer"&gt;Postgres Table Conventions — Naming, Keys and Audit Columns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/plpgsql-procedure-conventions/" rel="noopener noreferrer"&gt;PL/pgSQL Procedure Conventions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/plpgsql-function-conventions/" rel="noopener noreferrer"&gt;PL/pgSQL Function Conventions&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Starter kit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/marcusbelz/di2-starter-kit" rel="noopener noreferrer"&gt;The open DI² starter kit on GitHub&lt;/a&gt; — a project template with the rules structure described here; the font-size guard from this article ships with it as a reusable template.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>eslint</category>
      <category>tailwindcss</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Design Pattern // The Architecture of an ETL Process — How to Isolate Bad Data Cleanly</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:46:45 +0000</pubDate>
      <link>https://dev.to/marcus1968/design-pattern-the-architecture-of-an-etl-process-how-to-isolate-bad-data-cleanly-3b5h</link>
      <guid>https://dev.to/marcus1968/design-pattern-the-architecture-of-an-etl-process-how-to-isolate-bad-data-cleanly-3b5h</guid>
      <description>&lt;p&gt;A single date string that cannot be parsed, and the entire ETL run aborts. The design pattern for &lt;strong&gt;ETL process architecture&lt;/strong&gt; presented here prevents exactly that: bad data is isolated, not passed along.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — what this article covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Work packages and schema layering E0 – L2&lt;/strong&gt; — how to decompose the ETL process into distinct, self-contained packages, each with its own database schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical vs. structural transformation&lt;/strong&gt; — why separating type conversion and foreign-key resolution into two passes is safer and easier to debug than doing both in one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data quality at the schema boundaries&lt;/strong&gt; — erroneous records are caught at the transitions; the main stream keeps flowing cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historization as an optional layer&lt;/strong&gt; — SCD 1 / SCD 2 pay off mostly when delta loads are involved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prerequisite.&lt;/strong&gt; Basic familiarity with ETL processes. This is a conceptual article — not a step-by-step tutorial. Root of the article series: &lt;a href="https://sql.marcus-belz.de/en/data-quality-in-an-etl-process/" rel="noopener noreferrer"&gt;Data quality in an ETL process&lt;/a&gt;; the present article covers the architecture part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tasks of the ETL Process
&lt;/h2&gt;

&lt;p&gt;An ETL process consists of the three general steps &lt;strong&gt;E&lt;/strong&gt; = &lt;em&gt;Extract&lt;/em&gt;, &lt;strong&gt;T&lt;/strong&gt; = &lt;em&gt;Transform&lt;/em&gt; and &lt;strong&gt;L&lt;/strong&gt; = &lt;em&gt;Load&lt;/em&gt;. What exactly has to happen within each of these top-level steps, however, is a matter of definition.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extraction
&lt;/h3&gt;

&lt;p&gt;In this step, data is extracted from various data sources. Sources can be databases, files, or APIs. The data to be extracted may be structured or unstructured and may come in different formats. This article series deals exclusively with structured data. Structured data sources include relational databases, but also CSV documents as well as XML and JSON documents — as long as their data elements follow a logical structure. Unstructured data such as text from social networks is out of scope here.&lt;/p&gt;

&lt;p&gt;This generic definition leaves open what extraction concretely means. The following sections describe a concrete shape for the extraction process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Materialization of the extracted data&lt;/li&gt;
&lt;li&gt;Extended extraction tasks&lt;/li&gt;
&lt;li&gt;No type conversion of the data&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Materialization of Extracted Data
&lt;/h4&gt;

&lt;p&gt;The design pattern presented here stores all extracted data in a database. The data must be stored in such a way that, in this step, there is no technical reason for the data not to fit into the database. The only acceptable cause for aborting extraction is an infrastructure issue (storage, network, etc.). I refer to this writing-to-database step as the &lt;em&gt;materialization&lt;/em&gt; of the data.&lt;/p&gt;

&lt;p&gt;Extraction and the materialization of extracted data give three main benefits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The source system is read only once and as briefly as possible.&lt;/li&gt;
&lt;li&gt;All extracted data is available in a database for subsequent processing.&lt;/li&gt;
&lt;li&gt;After-the-fact error analysis on concrete records becomes possible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reading from the source system can put it under enough load that its performance and response times suffer. Extracting first minimizes the duration of that access.&lt;/p&gt;

&lt;p&gt;Once all extracted data sits in a database, downstream steps can work on it using SQL. No additional ETL tool is required just to integrate heterogeneous source systems. This lowers technical hurdles and, in practice, makes the downstream processes substantially more performant — both in execution and in development.&lt;/p&gt;

&lt;h4&gt;
  
  
  Extended Extraction Tasks
&lt;/h4&gt;

&lt;p&gt;For text files in &lt;strong&gt;XML&lt;/strong&gt; and &lt;strong&gt;JSON&lt;/strong&gt; format (and, depending on the case, CSV), materialization works a bit differently. &lt;strong&gt;XML&lt;/strong&gt; and &lt;strong&gt;JSON&lt;/strong&gt; documents are stored in the database &lt;em&gt;before&lt;/em&gt; their contained attributes are extracted. As an extended extraction task, the attributes are then extracted from the stored documents using powerful T-SQL functions such as &lt;strong&gt;OPENXML&lt;/strong&gt; or &lt;strong&gt;OPENJSON&lt;/strong&gt; and written to the database.&lt;/p&gt;

&lt;h4&gt;
  
  
  No Type Conversion of the Data
&lt;/h4&gt;

&lt;p&gt;Text-file deliveries are particularly problematic. The data they contain is not type-safe by any means. A date delivered as text may or may not be convertible into a &lt;code&gt;date&lt;/code&gt; value. There has to be an agreement between the source-data-producing process and the ETL process about, for example, which date format is used (yyyy/dd/MM, dd.MM.yyyy, etc.). Converting values during extraction is a source of errors and risks aborting the entire ETL run. Converting data into the target data types is therefore &lt;em&gt;not&lt;/em&gt; permitted during extraction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transformation
&lt;/h3&gt;

&lt;p&gt;A common definition of the transformation step goes something like: "Transformation converts the extracted data into the desired format." Another definition lumps all the tasks under the term &lt;em&gt;data integration&lt;/em&gt;. Both phrasings are vague and offer no concrete guidance.&lt;/p&gt;

&lt;p&gt;Starting from the extracted data, the design pattern presented here defines two mandatory tasks and one optional task:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Type conversion of the extracted data&lt;/li&gt;
&lt;li&gt;Data quality check&lt;/li&gt;
&lt;li&gt;Optional: historization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When data arrives as text files, the extracted attributes must first be converted into the target data types. This also applies when data is extracted from databases whose data types diverge from those in the target system. We focus here on text files as the data source. As described above, values extracted from text files are first stored as &lt;em&gt;text&lt;/em&gt;. The target system, however, expects strongly typed data. A date, for example, will routinely have to be converted into a value of type &lt;strong&gt;date&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The data quality check is, on top of that, a critically important task that fundamentally shapes the outcome of an ETL run. It starts with the question whether a delivered value can be converted into the data type of the corresponding target field. Where required, deliveries must also be checked for duplicates. There are many other useful and necessary checks and tasks that belong under the umbrella of data quality.&lt;/p&gt;

&lt;p&gt;In the historization step, source data identified as changed (new, modified, or deleted records) is rolled forward in separate tables, so it is always reproducible when a record was inserted, modified, or deleted. This step is optional. A colleague once called the historized data the &lt;em&gt;brain of the ETL process&lt;/em&gt;: the data of the downstream target system can be reconstructed from the historized data at any point. Of course, historization comes with additional maintenance tasks such as backups.&lt;/p&gt;

&lt;p&gt;The term &lt;em&gt;data integration&lt;/em&gt; is, in fact, closer to what we will call &lt;strong&gt;structural transformation&lt;/strong&gt;. There, data from various sources is filtered, merged, and aggregated. Although that is also a transformation task, the design pattern presented here does &lt;em&gt;not&lt;/em&gt; perform it during &lt;strong&gt;T&lt;/strong&gt; of the ETL process — it happens during &lt;strong&gt;L&lt;/strong&gt;. At this point, it pays to draw a sharp terminological line between the transformation tasks described in this section and structural transformation. The transformation tasks described here, performed during &lt;strong&gt;T&lt;/strong&gt;, are referred to as &lt;strong&gt;technical transformation&lt;/strong&gt;. The transformation tasks performed during &lt;strong&gt;L&lt;/strong&gt; are referred to as &lt;strong&gt;structural transformation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The boundaries between the three top-level ETL steps are fluid and, in the end, a matter of definition.&lt;/p&gt;

&lt;h4&gt;
  
  
  Typing of Extracted Data
&lt;/h4&gt;

&lt;p&gt;If the data source is a database such as &lt;em&gt;SQL Server&lt;/em&gt; or &lt;em&gt;Oracle&lt;/em&gt;, the data will typically already be strongly typed. Even so, type conversion may still be necessary to match the data types of the target system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example.&lt;/strong&gt; Consider the length of text fields or the storage of a date without a time zone. Application developers do not always pay close attention to input length limits. As a result, an address field in a source system might be able to hold entire novels. Users who notice such a gap will — empirically — happily use it to dump information that simply does not belong there. If the source system does not support storage of a date with time zone, the time zone of the source system must be determined and taken into account when converting to the target data type &lt;em&gt;date with time zone&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;When processing attributes extracted from a text file, typing the extracted values into the target data types is always required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example.&lt;/strong&gt; During extraction, attributes are stored as values of type &lt;em&gt;text&lt;/em&gt;. A text that looks like a date to us is not necessarily convertible into a &lt;em&gt;date&lt;/em&gt;. For instance, &lt;strong&gt;30-02-2023&lt;/strong&gt; is not a valid date. Another example: &lt;strong&gt;03-05-2023&lt;/strong&gt; cannot be interpreted as a date without additional context about the data source. Read in American style (&lt;code&gt;mm-dd-yyyy&lt;/code&gt;), it becomes &lt;strong&gt;05-Mar-2023&lt;/strong&gt;; read in the typical German style (&lt;code&gt;dd-mm-yyyy&lt;/code&gt;), it becomes &lt;strong&gt;03-May-2023&lt;/strong&gt;. Correct interpretation requires knowledge of the date format — that is, the format string. Similar challenges arise for numeric values where decimal and thousands separators must be agreed on.&lt;/p&gt;

&lt;h4&gt;
  
  
  Data Quality Check
&lt;/h4&gt;

&lt;p&gt;The data quality check inspects the extracted and converted data for completeness and correctness. These checks cover a wide field. Examples are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Type conversion check&lt;/li&gt;
&lt;li&gt;Duplicate identification&lt;/li&gt;
&lt;li&gt;Spelling and orthography check on text values&lt;/li&gt;
&lt;li&gt;Foreign key check&lt;/li&gt;
&lt;li&gt;Mandatory field missing value check&lt;/li&gt;
&lt;li&gt;Business logic validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The article &lt;a href="https://sql.marcus-belz.de/en/data-quality-in-an-etl-process/" rel="noopener noreferrer"&gt;Data quality in an ETL process&lt;/a&gt; introduces the term &lt;strong&gt;technical data quality&lt;/strong&gt;. The check on technical data quality operates on the typed data. For data quality checks on typed data, simple logical conditions can be set up, identifying errors on a value or per-record basis. A logical condition is technically expressed as a &lt;em&gt;WHERE&lt;/em&gt; clause in the ETL process and applied to the typed data. If a &lt;em&gt;WHERE&lt;/em&gt; clause returns records, those records contain an error in the inspected field.&lt;/p&gt;

&lt;h5&gt;
  
  
  Type Conversion Check
&lt;/h5&gt;

&lt;p&gt;Whether the type conversion succeeds or fails has direct impact on all downstream tasks. If an input value cannot be converted into the target data type, the offending record may have to be excluded from further processing. The design pattern presented here checks for every delivered source record whether its input values can be converted into the respective target data types.&lt;/p&gt;

&lt;h5&gt;
  
  
  Duplicate Identification
&lt;/h5&gt;

&lt;p&gt;Duplicate identification can be arbitrarily complex. In this article series, I limit myself to a combination of fields that, per the delivery contract, must follow a defined cardinality or must be unique (cardinality = 1).&lt;/p&gt;

&lt;h5&gt;
  
  
  Spelling and Orthography Check on Text Values
&lt;/h5&gt;

&lt;p&gt;Phone numbers, for instance, have many possible notations. The German &lt;em&gt;DIN 5008&lt;/em&gt; standard prescribes that the area code be written without parentheses and separated from the rest of the number by a single space. Notation checks on a value can be performed as part of the &lt;em&gt;technical transformation&lt;/em&gt;.&lt;/p&gt;

&lt;h5&gt;
  
  
  Foreign Key Check
&lt;/h5&gt;

&lt;p&gt;If the delivered data contains a foreign key relationship, only the &lt;strong&gt;structural validity&lt;/strong&gt; of a delivered foreign key value is checked here — that is, format, presence where required, and data type. The actual &lt;strong&gt;foreign key resolution&lt;/strong&gt; against the target system (mapping source-system code → target surrogate key) only happens later, as part of the structural transformation. The reason for the split: resolution needs context from the target system (such as a Countries table), whereas format and presence checks can be answered from the record alone.&lt;/p&gt;

&lt;h5&gt;
  
  
  Mandatory Field Missing Value Check
&lt;/h5&gt;

&lt;p&gt;If an attribute is a mandatory field in the target system, the typed data must be inspected to ensure that a corresponding value was delivered.&lt;/p&gt;

&lt;h5&gt;
  
  
  Business Logic Validation
&lt;/h5&gt;

&lt;p&gt;Checking business logic is itself a wide field that can become arbitrarily complex. Even checking simple business logic can substantially improve data quality. A simple example might be a customer's date of birth — which obviously must not lie in the future.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loading
&lt;/h3&gt;

&lt;p&gt;In the final step of the ETL process, the typed data is structurally transformed to match the data structures of the target system, optionally re-checked for data errors, filtered, aggregated, historized, and finally loaded into the target system. The tasks involved are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Structural transformation&lt;/li&gt;
&lt;li&gt;Data quality check&lt;/li&gt;
&lt;li&gt;Filtering&lt;/li&gt;
&lt;li&gt;Aggregation&lt;/li&gt;
&lt;li&gt;Optional: historization&lt;/li&gt;
&lt;li&gt;Loading the data into the target system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The previously technically transformed data can be loaded into different target systems. The target could be a CRM system or a data warehouse. The structural transformation task is specific to the chosen target system. That is why the structural transformation happens during &lt;strong&gt;L&lt;/strong&gt; of the ETL process. Again: the boundaries between the top-level ETL steps are fluid, and it is a matter of definition which tasks fall into which step.&lt;/p&gt;

&lt;h4&gt;
  
  
  Structural Transformation
&lt;/h4&gt;

&lt;p&gt;The structural transformation operates exclusively on the typed, quality-checked, and possibly historized data that was found to be error-free. Technically, the structural transformation corresponds to a SELECT statement joining historized tables and shaping the output to match the target system's data structures. Among other things, this step resolves foreign keys and lookup values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Foreign key resolution&lt;/li&gt;
&lt;li&gt;Lookup value resolution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output of the structural transformation is — as with extraction and the technical transformation — materialized in the database, so this data, too, is available for analysis and error diagnosis. The data structures of the structurally transformed data largely correspond to the structures in the target system. In particular, the column names and data types of the output are chosen to match those expected by the target.&lt;/p&gt;

&lt;h5&gt;
  
  
  Foreign Key Resolution
&lt;/h5&gt;

&lt;p&gt;If foreign keys cannot be determined from the extracted data alone, they must be looked up against the target system's data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example.&lt;/strong&gt; Target systems often store countries in a separate table. The country &lt;em&gt;United States&lt;/em&gt; is then identified both by its country name and — typically — by a technical key (for example, a GUID). When structurally transforming a customer whose source data identifies the country as the text &lt;em&gt;United States&lt;/em&gt;, this text must be translated to the primary key of &lt;em&gt;United States&lt;/em&gt; in the target system and stored as a foreign key with the customer record.&lt;/p&gt;

&lt;p&gt;Foreign key resolution requires either direct read access to the &lt;em&gt;Countries&lt;/em&gt; table in the target system or — if direct access is not available — that table must be read in advance and made available in the staging database. At that point, reading the &lt;em&gt;Countries&lt;/em&gt; table is itself an extraction task.&lt;/p&gt;

&lt;h5&gt;
  
  
  Lookup Value Resolution
&lt;/h5&gt;

&lt;p&gt;Source and target systems often use different codings for the value of a dropdown field. A dropdown field, for example, could be a list field for selecting a customer's salutation.&lt;/p&gt;

&lt;p&gt;In the database, what is shown and selected in the application is rarely stored verbatim. A salutation of &lt;em&gt;Mr.&lt;/em&gt; might be stored as the value &lt;em&gt;1&lt;/em&gt; and &lt;em&gt;Ms.&lt;/em&gt; as &lt;em&gt;2&lt;/em&gt;. The codings used in source and target systems typically differ.&lt;/p&gt;

&lt;p&gt;These coded attributes are often not stored in separate tables. Translating the source-system code into the target-system code therefore requires explicit knowledge of the translation rules. Following terminology used in &lt;em&gt;Microsoft CRM Dynamics&lt;/em&gt;, this translation is called &lt;em&gt;lookup value resolution&lt;/em&gt;. To resolve lookup values, the codes used by source and target systems must be determined and stored in a mapping table that is consulted during the structural transformation.&lt;/p&gt;

&lt;h4&gt;
  
  
  Data Quality Check
&lt;/h4&gt;

&lt;p&gt;Experience from real projects shows that foreign key resolution and lookup value resolution are major sources of errors — typically rooted in incomplete or incorrect mappings of source-system codes to target-system codes.&lt;/p&gt;

&lt;h4&gt;
  
  
  Filtering
&lt;/h4&gt;

&lt;p&gt;Unless the target system is being initially populated with data, only records with specific properties should be loaded into the target. Filtering for the records actually destined for loading should — where possible — already happen during the technical transformation. If that is not feasible there, filtering happens during the structural transformation.&lt;/p&gt;

&lt;h4&gt;
  
  
  Aggregation
&lt;/h4&gt;

&lt;p&gt;Data may need to be aggregated before loading into the target system.&lt;/p&gt;

&lt;p&gt;If end-to-end traceability of every processing step in the ETL pipeline is required, aggregation should be considered as a separate processing step downstream of the structural transformation. Aggregated data would then be stored in separate tables of the staging database.&lt;/p&gt;

&lt;h4&gt;
  
  
  Historization
&lt;/h4&gt;

&lt;p&gt;As in the technical transformation, the structurally transformed and checked data can be rolled forward in separate tables. New records are inserted, changed records are updated, and deleted records are flagged as deleted.&lt;/p&gt;

&lt;h4&gt;
  
  
  Loading Data Into the Target System
&lt;/h4&gt;

&lt;p&gt;The final loading of the changed data into the target system therefore operates on quality-assured, structurally transformed, and historized data. Only error-free records — those for which foreign keys and lookup values were successfully resolved — are loaded.&lt;/p&gt;

&lt;p&gt;Technologically, this article focuses on loading change data into a target database. The target database is updated via SQL statements, that is, INSERTs, UPDATEs, and where applicable DELETEs. Other target systems — such as Microsoft Dynamics 365 — require the use of a proprietary API, both for writing data into and reading data from the target. In that case, an ETL tool such as &lt;em&gt;SQL Server Integration Services&lt;/em&gt; is needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture of the ETL Process
&lt;/h2&gt;

&lt;p&gt;The architecture of the ETL process presented here is generic and can be used regardless of the kind of source data or target system, in data migration and data integration projects alike. It also fits the data-loading workflow of a data warehouse. The ETL process is decomposed into small, self-contained work packages. The tasks performed within a work package are sharply defined. During processing, data quality is checked at each step. After a work package finishes, only error-free data is handed over to the next package. At the end of the pipeline, quality-assured data sits in data structures similar to those of the target system and can be loaded there without further transformation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Work Packages of the ETL Process
&lt;/h3&gt;

&lt;p&gt;The following diagram illustrates the work packages of the ETL process presented here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzzcnhcnf3ihvwm5vlz53.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzzcnhcnf3ihvwm5vlz53.png" alt="Three-lane overview of the ETL process: at the top, the ETL steps Extract, Transform, Load; in the middle, the schemas E0, E1, T1, T2, L1, L2 sitting between data sources and the destination; at the bottom, the work packages from Data source through Load." width="800" height="185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The top lane of the diagram shows the top-level steps from the &lt;strong&gt;ETL&lt;/strong&gt; acronym: &lt;strong&gt;Extract&lt;/strong&gt;, &lt;strong&gt;Transform&lt;/strong&gt;, and &lt;strong&gt;Load&lt;/strong&gt;. The bottom lane names the concrete work packages of the ETL process and maps each to one of the top-level steps. Each work package is paired with a database schema. The middle lane labels the database schemas used per work package (&lt;strong&gt;E0&lt;/strong&gt;–&lt;strong&gt;L2&lt;/strong&gt;). Data is handed from work package to work package, that is, from schema to schema, as processing progresses. The ETL process consists of the following work packages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data extraction&lt;/li&gt;
&lt;li&gt;Technical transformation&lt;/li&gt;
&lt;li&gt;Historization of the technically transformed data&lt;/li&gt;
&lt;li&gt;Structural transformation&lt;/li&gt;
&lt;li&gt;Historization of the structurally transformed data&lt;/li&gt;
&lt;li&gt;Loading the data into the target system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;strong&gt;Technical Transformation&lt;/strong&gt; and &lt;strong&gt;Structural Transformation&lt;/strong&gt; work packages check the data quality of the transformed data and hand over only error-free data to the next package. In the diagram, these checks are indicated by the dark arrow heads. The sections below summarize the steps within each work package and provide an overview of the technology used to carry them out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extraction
&lt;/h3&gt;

&lt;p&gt;The goal of extraction is to first store all data to be processed in the staging database. Within extraction, it matters whether the source data comes from a database or from documents with table-like structures (such as EXCEL or CSV) — or from documents with complex logical structures (such as XML or JSON).&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Extraction From a Database&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfxowei192yer0ukctdw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfxowei192yer0ukctdw.png" alt="Detail diagram, extraction from a database: the active ETL step is Extract; the source Database is materialized into schema E1." width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When reading from a database or from table-like structures, the attributes / columns are first materialized into tables of schema &lt;strong&gt;E1&lt;/strong&gt;. The structures of the tables in schema &lt;strong&gt;E1&lt;/strong&gt; closely match the structures in the source system. When extracting from a database, the data is stored using the data types from the source system. If the source-system data types are not supported by &lt;em&gt;SQL Server&lt;/em&gt;, the data is stored in schema &lt;strong&gt;E1&lt;/strong&gt; as &lt;strong&gt;nvarchar&lt;/strong&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Extraction From Documents With Table-Like Structures&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6y193267fe66rfbiak2z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6y193267fe66rfbiak2z.png" alt="Detail diagram, extraction from EXCEL/CSV documents: the active step is Extract; the table-like data lands in schema E1 as text." width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Data from documents with table-like structures — such as EXCEL and CSV — cannot be delivered in a type-safe way. These documents are often hand-authored and hand-maintained, and the ETL process cannot assume that a column contains, say, a valid date. To make sure that all values from these documents can be materialized in the &lt;em&gt;staging database&lt;/em&gt; in tables of schema &lt;strong&gt;E1&lt;/strong&gt;, all data is first stored as &lt;strong&gt;nvarchar&lt;/strong&gt;. Use generous maximum text lengths to ensure that data can actually be materialized there.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Extraction From Documents With Complex Logical Structures&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo354fhxoafzjjaf129ul.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo354fhxoafzjjaf129ul.png" alt="Detail diagram, extraction from XML/JSON documents: two steps — documents are read (Extract documents) and then attributes are extracted (Extract attributes)." width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When XML or JSON documents are to be processed, the documents themselves are first stored in tables of schema &lt;strong&gt;E0&lt;/strong&gt;. Extraction of the attributes then happens into tables of schema &lt;strong&gt;E1&lt;/strong&gt;. The attribute extraction operates on the documents stored in schema &lt;strong&gt;E0&lt;/strong&gt; in the first step.&lt;/p&gt;

&lt;p&gt;Attributes from text files are stored in schema &lt;strong&gt;E1&lt;/strong&gt; as &lt;strong&gt;nvarchar&lt;/strong&gt;. Use generous maximum text lengths to ensure that the data fits.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Technology&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Extraction of data from a database or from table-like structures can be done with Microsoft's &lt;em&gt;SQL Server Integration Services&lt;/em&gt; (SSIS) or any other ETL tool. To extract XML or JSON documents, &lt;em&gt;SSIS&lt;/em&gt; first loads them into tables of schema &lt;strong&gt;E0&lt;/strong&gt;. The attribute extraction from the documents uses the powerful T-SQL functions &lt;strong&gt;OPENXML&lt;/strong&gt; or &lt;strong&gt;OPENJSON&lt;/strong&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Summary&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;This extraction approach has several advantages. Using an ETL tool such as &lt;em&gt;SSIS&lt;/em&gt; — which supports a high degree of parallelism in data processing — materialization into schemas &lt;strong&gt;E0&lt;/strong&gt; and &lt;strong&gt;E1&lt;/strong&gt; can be done with high throughput. Upstream systems are minimally impacted, and the data is available for further processing — including attribute extraction from XML and JSON documents via &lt;strong&gt;OPENXML&lt;/strong&gt; or &lt;strong&gt;OPENJSON&lt;/strong&gt; — in the staging database. The materialized data also enables root-cause analysis when errors arise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technical Transformation
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpyu6p8bv9ylymvt9a6m1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpyu6p8bv9ylymvt9a6m1.png" alt="Detail diagram, Technical Transformation: the active step is Transform; the three sub-steps Type Conversion, Check Conversion, and Calculate Hash Values move data from E1 to T1; erroneous records branch into an Error table." width="800" height="301"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Within the top-level transformation step, this design pattern performs the technical transformation as described above. It consists of the following sub-steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Type conversion&lt;/li&gt;
&lt;li&gt;Technical data quality check&lt;/li&gt;
&lt;li&gt;Data error logging&lt;/li&gt;
&lt;li&gt;Flagging of erroneous records&lt;/li&gt;
&lt;li&gt;Hash value computation&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Type Conversion&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The output of the technical transformation is typed data that matches the target system's expectations. Typing can be driven by metadata via generic user-defined stored procedures and materializes the data into tables of schema &lt;strong&gt;T1&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Per attribute from schema &lt;strong&gt;E1&lt;/strong&gt;, two columns are provided in schema &lt;strong&gt;T1&lt;/strong&gt;. The first column holds the extracted value in the data type used in schema &lt;strong&gt;E1&lt;/strong&gt;. The second column holds the typed value in the target data type — &lt;em&gt;if&lt;/em&gt; the value can be converted. If the value cannot be converted, the second column stores &lt;em&gt;NULL&lt;/em&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Technical Data Quality Check&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;After typing, the result is checked by comparing column pairs for type-conversion problems. Because the type conversion is purely technical, this check is also called &lt;em&gt;technical data quality check&lt;/em&gt;. The error check can already be extended here to cover simple business logic.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Data Error Logging&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Detected data errors are logged in a readable, queryable form in an error table.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Flagging of Erroneous Records&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;If a record contains at least one error, it is flagged as erroneous so it can be excluded from further processing. The flag lives in a column that stores the count of detected errors. Error-free records carry &lt;em&gt;NULL&lt;/em&gt; in this column.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Hash Value Computation&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The last sub-step of the technical transformation is computing and storing two hash values per record. The first hash represents the business-key columns of the record; the second hash represents all remaining columns. Through these two hashes, the next work package — &lt;strong&gt;Historization of Technically Transformed Data&lt;/strong&gt; — can identify change records. Hash values are computed only for error-free records.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Technology&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Conversion of extracted values into target data types, error checks, flagging of erroneous records, and hash value computation can all be implemented as generic stored procedures that build the appropriate dynamic SQL statements from metadata. This requires upfront investment in implementing those procedures. Once they exist, the tasks above reduce to simple procedure calls. In the long run, this reduces development effort and maximizes reuse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope of the dynamic part.&lt;/strong&gt; Dynamic SQL in the strict sense only appears in the data-quality check — one rule maps to one &lt;strong&gt;WHERE&lt;/strong&gt; clause applied to the typed table at run time. Beyond that, the procedures listed above (type conversion, DQ check, flagging, hash-value computation) are &lt;strong&gt;metadata-generatable&lt;/strong&gt;, because they follow the same structural pattern for every target table. This generation covers the corridor from extraction up to technical historization (schema &lt;strong&gt;T2&lt;/strong&gt;). From schema &lt;strong&gt;L1&lt;/strong&gt; onward — the structural transformation — the JOIN statements are target-system-specific and are developed manually; so are the historization procedures for schema &lt;strong&gt;L2&lt;/strong&gt; (see the corresponding sections below).&lt;/p&gt;

&lt;p&gt;How these generic checks are implemented in practice is shown in &lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Checking Data Quality with SQL&lt;/a&gt; — a configurable framework that handles the tasks listed above through metadata-driven procedures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Historization of Technically Transformed Data
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nw816humwmd36b65j2b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nw816humwmd36b65j2b.png" alt="Detail diagram, identification of change records: three New-record work packages show how new records are rolled forward into the historized tables." width="800" height="177"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Historization consists of the following sub-steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Historization&lt;/li&gt;
&lt;li&gt;Identification of change records&lt;/li&gt;
&lt;li&gt;Identification via hash values&lt;/li&gt;
&lt;li&gt;Storing hash values&lt;/li&gt;
&lt;li&gt;Promoting only error-free records&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Historization
&lt;/h4&gt;

&lt;p&gt;Historization means that delivered data is rolled forward in a database. In the data warehousing world, &lt;em&gt;&lt;a href="https://en.wikipedia.org/wiki/Slowly_changing_dimension" rel="noopener noreferrer"&gt;Slowly Changing Dimensions&lt;/a&gt;&lt;/em&gt; describes several types of historization that specify exactly how the rolling-forward works. &lt;em&gt;Slowly Changing Dimensions&lt;/em&gt; is also commonly abbreviated as &lt;em&gt;SCD&lt;/em&gt;. &lt;em&gt;Ralph Kimball&lt;/em&gt;'s canonical typology covers six types (SCD 1 through SCD 6); some sources additionally describe Type 0 (attributes that never change) and Type 7 (hybrid of surrogate and natural keys). Only two of these types are relevant here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SCD 1&lt;/strong&gt; — strictly speaking, no real historization at all. A record loaded earlier is simply overwritten by its changed counterpart. Only the most recent state of each record is ever stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCD 2&lt;/strong&gt; — every table that historizes data gets two extra columns &lt;strong&gt;ValidFrom&lt;/strong&gt; and &lt;strong&gt;ValidTill&lt;/strong&gt;, indicating the validity interval of the record. Currently valid records are open-ended, indicated for example by &lt;em&gt;NULL&lt;/em&gt; in &lt;strong&gt;ValidTill&lt;/strong&gt;. When a change record arrives for a currently valid record, the previously valid record's &lt;strong&gt;ValidTill&lt;/strong&gt; is set to the date from which the change record becomes valid, and the change record itself is inserted with &lt;strong&gt;ValidTill&lt;/strong&gt; = &lt;em&gt;NULL&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Historization is optional. With &lt;strong&gt;delta loads&lt;/strong&gt; it can be helpful or even required, however. Suppose a customer places a new order. In a delta load, the order is delivered, but not the customer (who has not changed). Resolving the foreign-key relationship between order and customer cannot be done from the delivered data alone. To resolve it, either the customer data has to be extracted from the target system, or customers must be historized in the staging database so they are available on subsequent ETL runs.&lt;/p&gt;

&lt;p&gt;In the context of the ETL process presented here, historization means that only error-free, changed records are historized. Historization can follow either &lt;em&gt;SCD 1&lt;/em&gt; or &lt;em&gt;SCD 2&lt;/em&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Identification of Change Records
&lt;/h4&gt;

&lt;p&gt;Historization requires being able to recognize change records in the source system and, subsequently, in the historized tables. Source systems often provide no information — or only unreliable information — about when a record was inserted, modified, or deleted. When a CSV file is generated from a hand-edited EXCEL document, for example, we can take it for granted that no reliable change information is available. Against that backdrop, this design pattern always derives change records from the data itself. The hash values computed during the Technical Transformation are used for this.&lt;/p&gt;

&lt;h4&gt;
  
  
  Identification via Hash Values
&lt;/h4&gt;

&lt;p&gt;In the Technical Transformation section, hash values were computed for error-free records — one over the business-key columns, one over the remaining columns. Both can be used to identify change records. New, modified, and deleted records can be identified by comparing the hashes of the business key and the attributes between the tables holding the extracted data (schema &lt;strong&gt;T1&lt;/strong&gt;) and the historized data (schema &lt;strong&gt;T2&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;Hash (business key)&lt;/th&gt;
&lt;th&gt;Hash (attributes)&lt;/th&gt;
&lt;th&gt;Type of change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;present in &lt;strong&gt;T1&lt;/strong&gt; and &lt;strong&gt;T2&lt;/strong&gt;, equal&lt;/td&gt;
&lt;td&gt;equal&lt;/td&gt;
&lt;td&gt;no change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;present in &lt;strong&gt;T1&lt;/strong&gt; and &lt;strong&gt;T2&lt;/strong&gt;, equal&lt;/td&gt;
&lt;td&gt;not equal&lt;/td&gt;
&lt;td&gt;record was modified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;only in &lt;strong&gt;T1&lt;/strong&gt; (extracted)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;new record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;only in &lt;strong&gt;T2&lt;/strong&gt; (historized)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;record was deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h5&gt;
  
  
  &lt;strong&gt;Storing Hash Values&lt;/strong&gt;
&lt;/h5&gt;

&lt;p&gt;When a new record is inserted into the historized tables, updated, or flagged there as deleted, the hash values of the new, modified, or deleted record are stored or updated accordingly. This ensures that the hash values stored there always represent the status quo of the source systems and that change records can be identified via hash values at any later point (in subsequent ETL runs).&lt;/p&gt;

&lt;h5&gt;
  
  
  &lt;strong&gt;Promoting Only Error-Free Records&lt;/strong&gt;
&lt;/h5&gt;

&lt;p&gt;Promoting an erroneous record — and later loading it into the target system — could cause an error and potentially abort the entire ETL run. Therefore, only error-free change records from schema &lt;strong&gt;T1&lt;/strong&gt; are stored in schema &lt;strong&gt;T2&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structural Transformation
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2lvm6ia1a3f61d6vzbb5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2lvm6ia1a3f61d6vzbb5.png" alt="Detail diagram, Structural Transformation: the active step is Load; the sub-steps Type Conversion, Check Conversion, and Calculate Hash Values, with annotations for foreign-key and lookup-value resolution; erroneous records branch into the Error table." width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The structural transformation consists of the following sub-steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Structural transformation and resolution of foreign-key relationships and lookup values&lt;/li&gt;
&lt;li&gt;Structural data quality check&lt;/li&gt;
&lt;li&gt;Data error logging&lt;/li&gt;
&lt;li&gt;Flagging of erroneous records&lt;/li&gt;
&lt;li&gt;Hash value computation&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Structural Transformation and Resolution of Foreign Key Relationships and Lookup Values&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The output of the structural transformation is data in table structures matching the target system. The structural transformation is implemented as SQL statements with the required JOINs in the FROM clause. Developing those statements requires solid knowledge of the data, the relationships among entities, and especially the foreign-key relationships among tables in the source system — or among the source systems being integrated.&lt;/p&gt;

&lt;p&gt;Besides the actual structural transformation of source data, the structural transformation resolves foreign-key relationships for the target system and determines the codes to store for lookup values. The result is stored in tables of schema &lt;strong&gt;L1&lt;/strong&gt;, whose structure, column names, and data types resemble those of the target system.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Structural Data Quality Check&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;After the structural transformation, the result is checked: could all foreign-key relationships and lookup values be resolved? If no foreign key or no lookup code can be determined for a record, the record counts as erroneous. Since this check concerns the outcome of the structural transformation, it is called the &lt;em&gt;structural data quality check&lt;/em&gt; here.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Data Error Logging&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Detected data errors are logged in a readable, queryable form in an error table.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Flagging of Erroneous Records&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;If a record contains at least one error, it is flagged as erroneous so it can be excluded from further processing. The flag lives in a column that stores the count of detected errors. Error-free records carry &lt;em&gt;NULL&lt;/em&gt; in this column.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Hash Value Computation&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The last sub-step of the structural transformation is computing two hash values per record. The first hash represents the business-key columns of a structurally transformed record; the second hash represents all remaining attribute columns. Both hashes let the next work package — &lt;em&gt;Historization of Structurally Transformed Data&lt;/em&gt; — identify change records.&lt;/p&gt;

&lt;h3&gt;
  
  
  Historization of Structurally Transformed Data
&lt;/h3&gt;

&lt;p&gt;Historization of the structurally transformed data covers the same sub-steps as historization of the technically transformed data. It is an optional step, because — as long as the data from the technical transformation is historized — the structurally transformed data can always be reconstructed by running the structural transformation again.&lt;/p&gt;

&lt;p&gt;Historization of the structurally transformed data takes the records from tables in schema &lt;strong&gt;L1&lt;/strong&gt; and stores them in tables of schema &lt;strong&gt;L2&lt;/strong&gt;. The approach is identical to historizing data from schema &lt;strong&gt;T1&lt;/strong&gt; into schema &lt;strong&gt;T2&lt;/strong&gt;. Only error-free change records are historized from &lt;strong&gt;L1&lt;/strong&gt; into &lt;strong&gt;L2&lt;/strong&gt;. New, changed, and deleted records are additionally marked with a flag indicating that they still need to be loaded into the target system. If the data of schema &lt;strong&gt;L2&lt;/strong&gt; is historized as well, it must never be deleted and should be backed up by a maintenance process. This way, it is always possible to trace when which record changed.&lt;/p&gt;

&lt;p&gt;The procedures required to historize data into schema &lt;strong&gt;L2&lt;/strong&gt; must be developed manually.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loading
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2goe8tinww77vtzodxqs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2goe8tinww77vtzodxqs.png" alt="Detail diagram, loading into the target system: the active step is Load; Load data moves records from schema L2 into the Destination; Mark loaded data marks them in L2 as loaded." width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The transformed and quality-checked data in schema &lt;strong&gt;L2&lt;/strong&gt; can now be loaded into the target system using a technology of choice. The change records to be loaded are identified via a flag indicating whether the record has already been loaded. Records loaded successfully into the target system are flagged accordingly.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What is the difference between technical and structural transformation?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The technical transformation works on each record in isolation: it converts input values into the target data types (text → &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;decimal&lt;/code&gt;, …) and runs a first data-quality check at the value level, both without looking at other tables. The structural transformation, by contrast, needs context from the target system — resolving foreign keys, mapping lookup values — and therefore happens in its own work package after the technical transformation. Splitting them lets the two classes of errors be logged and fixed separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why materialize every work package in the database?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Materialization — writing each work package's output to a database table — decouples three things: the source system is read only once and runs unobserved by downstream steps; every step becomes re-startable without rerunning the entire ETL pipeline; and a traceable audit trail emerges for diagnosing errors on individual records. The storage overhead is negligible compared with the robustness gained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I really need all six persistence layers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not strictly. The full E0/E1/T1/T2/L1/L2 layering pays off mostly where audit trail, per-package restartability, and after-the-fact error analysis are hard requirements — typically in classical migration and CRM-integration projects with data volumes in the low to medium range (≤100M records per run). For large volumes or modern platforms such as Snowflake, Databricks, or BigQuery, some intermediate layers are often implemented as &lt;strong&gt;views&lt;/strong&gt; rather than materialized tables — the architectural logic stays the same while storage and I/O overhead drop. Rule of thumb: &lt;strong&gt;T2&lt;/strong&gt; and &lt;strong&gt;L1&lt;/strong&gt; are the first candidates for virtualization, because they can always be reconstructed from T1 and T2 respectively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When do I need historization (SCD)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Historization pays off when the ETL process handles &lt;strong&gt;delta loads&lt;/strong&gt; — that is, only the changes since the last run, not a full snapshot. In a delta load, the order record is delivered but not the related customer (if the customer has not changed); without historized customer data, the foreign-key relationship cannot be resolved. With full snapshot loads — where every run pulls the entire source system — SCD is optional and usually unnecessary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can this pattern be used with Postgres instead of SQL Server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core concepts of the pattern — work packages, schema layering E0–L2, data quality at the boundaries, hash-based SCD — are not tied to SQL Server and can be implemented in any relational database. The &lt;strong&gt;code&lt;/strong&gt; presented here, however, is consistently T-SQL / SQL Server-centric (SSIS, &lt;code&gt;OPENXML&lt;/code&gt;, &lt;code&gt;OPENJSON&lt;/code&gt;, &lt;code&gt;HASHBYTES&lt;/code&gt;, metadata-driven stored-procedure generation). The most important Postgres equivalents are: &lt;code&gt;xmltable()&lt;/code&gt; for XML, &lt;code&gt;jsonb_to_recordset()&lt;/code&gt; or &lt;code&gt;JSON_TABLE&lt;/code&gt; (from Postgres 17) for JSON, and &lt;code&gt;digest(…, 'sha256')&lt;/code&gt; from &lt;code&gt;pgcrypto&lt;/code&gt; for hashes. Structural adaptation can go deeper than just renaming functions — for example, the E0/E1 split for XML/JSON can often be dropped in Postgres because &lt;code&gt;xmltable()&lt;/code&gt; extracts directly from the source read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does this pattern relate to Data Vault 2.0?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are clear similarities — business key, hash-based delta detection, layer separation into Raw / Cleansed / Business, auditability. Readers familiar with Data Vault will recognize &lt;strong&gt;E1&lt;/strong&gt;/&lt;strong&gt;T2&lt;/strong&gt; as a "Raw + Hub/Satellite-equivalent" and &lt;strong&gt;L1&lt;/strong&gt;/&lt;strong&gt;L2&lt;/strong&gt; as the "Business Vault". The pattern presented here is, however, &lt;strong&gt;lighter&lt;/strong&gt;: no strict Hub/Link/Satellite separation, no mandatory insert-only history, no Raw-Vault-vs-Business-Vault architectural dogma. For classical migration and CRM-integration projects with audit requirements, this simplification is pragmatic; for pure data-warehouse loading with multi-source integration, Data Vault 2.0 is worth a look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-in-an-etl-process/" rel="noopener noreferrer"&gt;Data quality in an ETL process&lt;/a&gt; — root of the article series.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Checking Data Quality with SQL — a Configurable Framework&lt;/a&gt; — the implementation layer beneath this architecture: spotting bad data generically and classifying it by severity.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/logging-an-etl-process-with-t-sql/" rel="noopener noreferrer"&gt;Design Pattern // Logging an ETL process with T-SQL&lt;/a&gt; — cluster sibling covering the logging layer.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/safe-type-conversion-t-sql/" rel="noopener noreferrer"&gt;Design Pattern // Safe Type Conversion with T-SQL&lt;/a&gt; — &lt;code&gt;fn_try_convert_*&lt;/code&gt; UDFs for the technical transformation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/type-conversion-basics-t-sql/" rel="noopener noreferrer"&gt;Data Quality // Fundamentals of Type Conversion with T-SQL&lt;/a&gt; — foundational article on &lt;code&gt;TRY_CONVERT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/etl-vs-elt-explained/" rel="noopener noreferrer"&gt;ETL vs. ELT — How to Tell Which Pattern You Actually Built&lt;/a&gt; — classifies the architecture presented here as persistent-staging ELT.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sql</category>
      <category>etl</category>
      <category>database</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Checking Data Quality with SQL — a Configurable Framework for Spotting Bad Data Generically</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Thu, 16 Jul 2026 21:12:39 +0000</pubDate>
      <link>https://dev.to/marcus1968/checking-data-quality-with-sql-a-configurable-framework-for-spotting-bad-data-generically-2cg9</link>
      <guid>https://dev.to/marcus1968/checking-data-quality-with-sql-a-configurable-framework-for-spotting-bad-data-generically-2cg9</guid>
      <description>&lt;p&gt;Bad data gives no warning. An age of 200 years, a duplicate customer number, a country code that doesn't exist — in the source system nobody notices. Only when the ETL run tries to push the rows into the strictly modelled target layer does the load break: on a &lt;code&gt;CHECK&lt;/code&gt;, on a &lt;code&gt;UNIQUE&lt;/code&gt; index, on a foreign key. &lt;strong&gt;Checking data quality with SQL&lt;/strong&gt; means finding exactly those rows beforehand, classifying them by severity and sorting them out deliberately — without a special tool, with a handful of generic SQL routines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The essentials up front:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three generic check routines — a &lt;strong&gt;WHERE clause&lt;/strong&gt;, a &lt;strong&gt;uniqueness check&lt;/strong&gt; and a &lt;strong&gt;foreign-key check&lt;/strong&gt; — cover a large share of typical data errors.&lt;/li&gt;
&lt;li&gt;All three write into &lt;strong&gt;one shared error table&lt;/strong&gt;: one row per violation, with the business key, the offending value and a plain-text message.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;severity&lt;/strong&gt; (error / warning / information) drives a &lt;strong&gt;quality gate&lt;/strong&gt;: only error-free rows flow on.&lt;/li&gt;
&lt;li&gt;All in plain PL/pgSQL — the same basic principle that specialised data-quality tools use too, here dependency-free to build yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prerequisite:&lt;/strong&gt; Postgres as the example engine and a central staging layer that is loaded &lt;em&gt;raw&lt;/em&gt; first. The checks run against it set-based. That is the counter-design to the tool-centric package that mixes extraction, transformation and loading per table and scatters the quality check across the whole process (more on that in the architecture article of this series).&lt;/p&gt;

&lt;h2&gt;
  
  
  Data quality with SQL: the dimensions behind it
&lt;/h2&gt;

&lt;p&gt;There is plenty to read about data quality and little to grab hold of. The literature has agreed for decades on &lt;em&gt;what&lt;/em&gt; makes data good: Wang &amp;amp; Strong described fifteen dimensions in their 1996 paper "Beyond Accuracy", the DAMA UK working group singled out six of them in 2013 as core dimensions for practice (completeness, uniqueness, timeliness, validity, accuracy, consistency), and ISO/IEC 25012 standardises data-quality characteristics as a norm. What the literature rarely delivers is the &lt;em&gt;how&lt;/em&gt; — and when it does, it is usually tied to a particular tool.&lt;/p&gt;

&lt;p&gt;The concrete lever, though, is obvious: anyone who wants to check data quality with SQL formulates the check as a query and ends up with a table holding the bad records. Three routines cover a large share of practice, and each one cleanly maps to one of the established dimensions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check routine&lt;/th&gt;
&lt;th&gt;What it finds&lt;/th&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WHERE clause on one table&lt;/td&gt;
&lt;td&gt;values outside allowed ranges, missing mandatory values&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Validity&lt;/strong&gt; (+ Completeness)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uniqueness / maximum occurrences&lt;/td&gt;
&lt;td&gt;duplicates, over-frequent keys&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Uniqueness&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign key against a reference&lt;/td&gt;
&lt;td&gt;orphaned rows without a master&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Consistency / Integrity&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That covers four of the six dimensions; timeliness and accuracy — in the sense of matching the real world — lie outside their reach and need other means. The theory behind it — error classes, the criteria canon, and the full coverage map including the two blind spots — is deepened in the concept article &lt;a href="https://sql.marcus-belz.de/en/data-quality-dimensions-error-classes/" rel="noopener noreferrer"&gt;Data Quality: Dimensions and Error Classes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Honestly placed: this approach is not new. Tools like Soda Core, dbt tests or Great Expectations follow the same approach at the core — formulate check logic, collect the hits, attach a severity; whether that happens as generated SQL or against another engine (Pandas, Spark, a data warehouse) is an implementation detail. Around that, granted, they offer more, from monitoring through profiling to lineage. The value of the home-built version is not originality but transparency: every line is readable, nothing is bound to a product, and it runs everywhere you aren't allowed to install an extra tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common denominator: one error table
&lt;/h2&gt;

&lt;p&gt;The backbone is not the check but its result. All three routines write into the &lt;strong&gt;same&lt;/strong&gt; table — one row per violation found, stored so that the source record can later be identified unambiguously:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;id&lt;/span&gt;             &lt;span class="nb"&gt;bigint&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;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;schema_name&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id1_column&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id1_value&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id2_column&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id2_value&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id3_column&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id3_value&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;error_column&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;error_value&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;       &lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;created_on&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="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;pk_error&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;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;ck_error_severity&lt;/span&gt;   &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'E'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'W'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'I'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
 &lt;span class="mi"&gt;19&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 column pairs &lt;code&gt;id1_column&lt;/code&gt;/&lt;code&gt;id1_value&lt;/code&gt; through &lt;code&gt;id3_column&lt;/code&gt;/&lt;code&gt;id3_value&lt;/code&gt; hold the &lt;strong&gt;business key&lt;/strong&gt; of the affected record: &lt;code&gt;id1_column&lt;/code&gt; holds the &lt;em&gt;column name&lt;/em&gt; (say &lt;code&gt;customer_id&lt;/code&gt;), &lt;code&gt;id1_value&lt;/code&gt; the &lt;em&gt;value&lt;/em&gt; (say &lt;code&gt;4711&lt;/code&gt;). That lets you reconstruct the bad row later — &lt;code&gt;WHERE customer_id = 4711&lt;/code&gt;. Three pairs are enough for composite keys; in practice one almost always suffices. Alongside, each row records the &lt;strong&gt;checked column&lt;/strong&gt; (&lt;code&gt;error_column&lt;/code&gt;), the &lt;strong&gt;offending value&lt;/strong&gt; (&lt;code&gt;error_value&lt;/code&gt;), the &lt;strong&gt;message&lt;/strong&gt; in plain text and the &lt;strong&gt;severity&lt;/strong&gt; (&lt;code&gt;severity&lt;/code&gt;: &lt;code&gt;E&lt;/code&gt;rror, &lt;code&gt;W&lt;/code&gt;arning, &lt;code&gt;I&lt;/code&gt;nformation).&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration: one rule per row
&lt;/h2&gt;

&lt;p&gt;The check rules are not cast into code but written into a table. One row = one rule. New checks arrive without a deployment, and the business side can read along with what is being checked:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_rule&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="nb"&gt;bigint&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;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;check_type&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;   &lt;span class="c1"&gt;-- 'constraint' | 'unique' | 'lookup'&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;schema_name&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id1_column&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;   &lt;span class="c1"&gt;-- business key (up to three)&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id2_column&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id3_column&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;check_column&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;   &lt;span class="c1"&gt;-- checked column&lt;/span&gt;
 &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;where_clause&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;               &lt;span class="c1"&gt;-- 'constraint': the "bad" predicate&lt;/span&gt;
 &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;max_occurrence&lt;/span&gt;  &lt;span class="nb"&gt;int&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;-- 'unique': allowed occurrences&lt;/span&gt;
 &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;ref_schema&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;               &lt;span class="c1"&gt;-- 'lookup': reference table&lt;/span&gt;
 &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;ref_table&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;ref_column&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;        &lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'E'&lt;/span&gt;
 &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;          &lt;span class="nb"&gt;boolean&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="k"&gt;true&lt;/span&gt;
 &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;pk_check_rule&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;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="mi"&gt;20&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;&lt;code&gt;check_type&lt;/code&gt; decides which of the three routines is built for the row. Depending on the type, different columns matter: &lt;code&gt;where_clause&lt;/code&gt; for the WHERE check, &lt;code&gt;max_occurrence&lt;/code&gt; for uniqueness, &lt;code&gt;ref_schema&lt;/code&gt;/&lt;code&gt;ref_table&lt;/code&gt;/&lt;code&gt;ref_column&lt;/code&gt; for the foreign key. &lt;code&gt;severity&lt;/code&gt; and &lt;code&gt;message&lt;/code&gt; hang on every rule — so each finding carries its severity and its plain text straight from the configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routine 1: the WHERE clause
&lt;/h2&gt;

&lt;p&gt;The simplest and at the same time most powerful check: a condition that describes &lt;em&gt;bad&lt;/em&gt; rows, attached to a table. Everything the condition matches is a finding. For the rule "age must be between 0 and 120" the routine produces this statement:&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="mi"&gt;1&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;dq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;schema_name&lt;/span&gt;&lt;span class="p"&gt;,&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;id1_column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id1_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="n"&gt;error_column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="s1"&gt;'staging'&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'customer'&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'customer_id'&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;T01&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="nb"&gt;text&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'age'&lt;/span&gt;
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;T01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'E'&lt;/span&gt;
 &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Age out of range 0..120'&lt;/span&gt;
 &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
 &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="n"&gt;T01&lt;/span&gt;
 &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt;
 &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The predicate on line 15 comes unchanged from &lt;code&gt;where_clause&lt;/code&gt;. With that, this one routine covers a whole family: ranges (&lt;code&gt;age &amp;lt; 0 OR age &amp;gt; 120&lt;/code&gt;), mandatory fields (&lt;code&gt;email IS NULL&lt;/code&gt;), formats (&lt;code&gt;length(zip) &amp;lt;&amp;gt; 5&lt;/code&gt;), plausibility (&lt;code&gt;order_date &amp;gt; current_date&lt;/code&gt;). Two dimensions at once — validity and completeness. How to push this routine to its limits — and which NULL trap of three-valued logic it has to avoid — is covered in depth by the spoke &lt;a href="https://sql.marcus-belz.de/en/validate-data-with-sql/" rel="noopener noreferrer"&gt;Validating Data with SQL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routine 2: uniqueness and cardinality
&lt;/h2&gt;

&lt;p&gt;Duplicate keys are the classic case. The check is a &lt;code&gt;GROUP BY&lt;/code&gt; with &lt;code&gt;HAVING&lt;/code&gt; on the count:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;customer_id&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;customer_id&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;HAVING&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real trick sits in the &lt;code&gt;&amp;gt; 1&lt;/code&gt;: it comes from &lt;code&gt;max_occurrence&lt;/code&gt;. Instead of checking for &lt;em&gt;uniqueness&lt;/em&gt;, you check for a &lt;em&gt;maximum count&lt;/em&gt; — &lt;code&gt;&amp;gt; 1&lt;/code&gt; for true uniqueness, &lt;code&gt;&amp;gt; 3&lt;/code&gt; for example when a key may appear at most three times. The routine then joins the keys it found back onto the table and logs &lt;strong&gt;every&lt;/strong&gt; occurrence (not just the first), so that every affected row appears in the error table.&lt;/p&gt;

&lt;p&gt;What is checked here is exactly the business key that identifies the record — the same column that lands in the error table as &lt;code&gt;id1_column&lt;/code&gt;. That is no coincidence: this check directly mirrors the &lt;code&gt;UNIQUE&lt;/code&gt; constraint that the strict target layer carries on the business key. What &lt;em&gt;must&lt;/em&gt; be unique there is caught up front in the source. How to push this routine — maximum cardinality, composite keys and the NULL trap in &lt;code&gt;UNIQUE&lt;/code&gt;, where SQL Server and Postgres differ — is deepened by the spoke &lt;a href="https://sql.marcus-belz.de/en/find-duplicates-with-sql/" rel="noopener noreferrer"&gt;Finding Duplicates with SQL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routine 3: referential integrity
&lt;/h2&gt;

&lt;p&gt;A foreign key into the void — a &lt;code&gt;country_code&lt;/code&gt; for which there is no entry in the master-data table. Put generically: all rows of the child table that find no partner in the master table via &lt;code&gt;LEFT JOIN&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;T01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="n"&gt;T01&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="n"&gt;T02&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;ON&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;T01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;T02&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt;
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;T01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;T02&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country_code&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;Child table, child column (&lt;code&gt;check_column&lt;/code&gt;) and master (&lt;code&gt;ref_schema&lt;/code&gt;/&lt;code&gt;ref_table&lt;/code&gt;/&lt;code&gt;ref_column&lt;/code&gt;) come from the configuration — so the same routine works for any master-child relationship. The &lt;code&gt;IS NOT NULL&lt;/code&gt; condition on line 9 deliberately separates "unknown value" (an error) from "no value given" (that is the WHERE routine's job). How to push this routine — the three phrasings &lt;code&gt;LEFT JOIN … IS NULL&lt;/code&gt;/&lt;code&gt;NOT EXISTS&lt;/code&gt;/&lt;code&gt;NOT IN&lt;/code&gt;, the notorious &lt;code&gt;NOT IN&lt;/code&gt;-plus-&lt;code&gt;NULL&lt;/code&gt; trap, and composite and self-referencing foreign keys — is deepened by the spoke &lt;a href="https://sql.marcus-belz.de/en/find-orphaned-records-sql/" rel="noopener noreferrer"&gt;Finding Orphaned Records with SQL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runner: dynamic SQL — done safely
&lt;/h2&gt;

&lt;p&gt;The three statements above are hard-coded. They become generic when a function assembles them at runtime from the configuration. In PL/pgSQL, &lt;code&gt;format()&lt;/code&gt; is the right tool — and the point where you have to be careful. Identifiers belong in with &lt;code&gt;%I&lt;/code&gt;, literals with &lt;code&gt;%L&lt;/code&gt;; both are quoted correctly by Postgres and rule out SQL injection through table and column names. Here is the branch for the WHERE check:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;l_sql&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt;&lt;span class="err"&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;dq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                          &lt;span class="k"&gt;schema_name&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id1_column&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;id1_value&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;error_column&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;error_value&lt;/span&gt;
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;
 &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="k"&gt;SELECT&lt;/span&gt;
 &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                          &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;
 &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;
 &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;
 &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;T01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;
 &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;T01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;
 &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;
 &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="k"&gt;FROM&lt;/span&gt;
 &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;T01&lt;/span&gt;
 &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="k"&gt;WHERE&lt;/span&gt;
 &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                         &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;
 &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;
 &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;schema_name&lt;/span&gt;
 &lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;
 &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id1_column&lt;/span&gt;
 &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id1_column&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;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_column&lt;/span&gt;
 &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_column&lt;/span&gt;
 &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;
 &lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;
 &lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;schema_name&lt;/span&gt;
 &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;
 &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;l_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;where_clause&lt;/span&gt;
 &lt;span class="mi"&gt;37&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
 &lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;l_sql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema and column names (lines 16, 18, 22) go through &lt;code&gt;%I&lt;/code&gt;, the fixed values through &lt;code&gt;%L&lt;/code&gt;. The uniqueness and foreign-key branches are built on the same pattern — only the inner statement differs.&lt;/p&gt;

&lt;p&gt;One spot stays deliberately raw: the predicate on line 24 is inserted with &lt;code&gt;%s&lt;/code&gt;, i.e. as unchanged SQL. It &lt;strong&gt;has&lt;/strong&gt; to be — &lt;code&gt;where_clause&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; a SQL expression, not a value. That makes the &lt;code&gt;dq.check_rule&lt;/code&gt; table the &lt;strong&gt;trust boundary&lt;/strong&gt; of the system: whoever may write there can have arbitrary SQL executed. In practice this is uncritical, because the configuration is maintained administratively and never filled from user input — but you have to know it and secure it. Identifiers, by contrast, are watertight through &lt;code&gt;%I&lt;/code&gt;: a column name like &lt;code&gt;age"; DROP TABLE staging.customer; --&lt;/code&gt; from the configuration becomes a (non-existent) quoted identifier and raises a clean error instead of dropping the table.&lt;/p&gt;

&lt;p&gt;Across all active rules of a table the runner loops, executes the built statement per rule and finally writes the severity counters back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Take it with you:&lt;/strong&gt; The complete framework — error table, rule table, runner, demo data and gate query — is available as a runnable &lt;a href="https://sql.marcus-belz.de/en/download/2940/" rel="noopener noreferrer"&gt;SQL bundle for download&lt;/a&gt; (PostgreSQL 13+, verified against Postgres 16). An empty database is all you need; the script sets up everything itself.&lt;/p&gt;

&lt;p&gt;A word on runtime, to be honest: most freely configured predicates run as a full-table scan over the source table, because no matching index exists for an arbitrary expression — across many rules on large tables that adds up. A simple &lt;code&gt;email IS NULL&lt;/code&gt; or a key check may well use an index, but with freely configured rules you cannot rely on it. It is acceptable because the check runs in the staging window that is scheduled anyway, against the freshly loaded set, not against the production system. For very large tables it pays to limit the check to the partitions or batches loaded in the current run, instead of scanning everything every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Severity and the quality gate
&lt;/h2&gt;

&lt;p&gt;Up to here, &lt;code&gt;dq.error&lt;/code&gt; holds &lt;em&gt;what&lt;/em&gt; is wrong. Steering the ETL process, however, needs a statement &lt;em&gt;per record&lt;/em&gt;: may it proceed or not? For that the source table gets three counter columns — &lt;code&gt;sys_error&lt;/code&gt;, &lt;code&gt;sys_warning&lt;/code&gt;, &lt;code&gt;sys_info&lt;/code&gt; — and the runner fills them after each run: per business key the number of findings by severity.&lt;/p&gt;

&lt;p&gt;The write-back here runs over the &lt;strong&gt;single-column&lt;/strong&gt; business key (&lt;code&gt;id1&lt;/code&gt;) — by far the most common case. Gating composite keys across several columns would work the same way but is material for its own spoke; the error table already holds the key parts &lt;code&gt;id1&lt;/code&gt;–&lt;code&gt;id3&lt;/code&gt; for it.&lt;/p&gt;

&lt;p&gt;That turns the gate into a trivial WHERE condition:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;customer_id&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt;
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;sys_error&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only rows without an error flow into the next layer. &lt;strong&gt;Warnings and information do not block&lt;/strong&gt; — they are logged but no obstacle. That is the whole point of severity: it separates "must not proceed" from "worth a look". In the demo set of seven rows exactly two pass the gate — the clean row and the row with the missing email (a warning only). Everything with &lt;code&gt;age&lt;/code&gt; out of range, the unknown country and the duplicate customer number stays behind, neatly logged:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;severity&lt;/th&gt;
&lt;th&gt;id1_value&lt;/th&gt;
&lt;th&gt;error_column&lt;/th&gt;
&lt;th&gt;error_value&lt;/th&gt;
&lt;th&gt;message&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;customer_id&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;customer_id not unique&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;customer_id&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;customer_id not unique&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;age&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Age out of range 0..120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;country_code&lt;/td&gt;
&lt;td&gt;XX&lt;/td&gt;
&lt;td&gt;Unknown country_code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;age&lt;/td&gt;
&lt;td&gt;-3&lt;/td&gt;
&lt;td&gt;Age out of range 0..120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;email&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Email missing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why not just constraints?
&lt;/h2&gt;

&lt;p&gt;The obvious question: if the target layer has &lt;code&gt;CHECK&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt; and foreign-key constraints anyway — why the effort? The answer lies precisely there. The downstream tables &lt;em&gt;are&lt;/em&gt; strictly modelled; that is intended. But a constraint knows only two outcomes: the row fits, or the whole load breaks. When loading thousands of rows, "breaks" is the worst of all options — a single bad row stops the entire process, and you don't even know which one.&lt;/p&gt;

&lt;p&gt;That is exactly why you check in the &lt;strong&gt;source up front&lt;/strong&gt;: you identify &lt;em&gt;all&lt;/em&gt; rows that would fail at the target constraints, classify them by severity and let only the clean ones pass. The framework does not rebuild the constraints — it is the transparent, auditable pre-filter &lt;em&gt;before&lt;/em&gt; a deliberately strict target layer. Instead of an aborted load you get a table of findings and a process that carries on with the good data.&lt;/p&gt;

&lt;p&gt;In fact the three routines are exactly the pre-filter for the three constraint types the target enforces:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint at the target&lt;/th&gt;
&lt;th&gt;Check routine in the source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CHECK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WHERE clause (Routine 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNIQUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Uniqueness (Routine 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FOREIGN KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Foreign key (Routine 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What is &lt;em&gt;enforced&lt;/em&gt; as a constraint at the target is checked in the source &lt;em&gt;up front&lt;/em&gt; — on the same business key that carries the &lt;code&gt;UNIQUE&lt;/code&gt; constraint at the target. The difference is not &lt;em&gt;what&lt;/em&gt; is checked but &lt;em&gt;how&lt;/em&gt; the violation is handled: report and classify instead of aborting the load.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this approach does not cover
&lt;/h2&gt;

&lt;p&gt;Three routines are a lot, but not everything — and it pays to be honest about where the line lies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repair&lt;/strong&gt; is deliberately not part of it. The framework &lt;em&gt;finds&lt;/em&gt; and &lt;em&gt;makes transparent&lt;/em&gt;; it corrects nothing. That is a decision, not an omission — transparency first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-field business rules&lt;/strong&gt; ("discount only if status = active") can sometimes be expressed as a WHERE clause, sometimes not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal consistency, accuracy against an external truth, complex patterns&lt;/strong&gt; (beyond simple length and format checks) are topics of their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if you'd rather use a finished tool: Soda Core, dbt tests and Great Expectations cover the same area, free and well maintained. The SQL home-build pays off when you want transparency, zero dependencies and full control over every line — or simply work in an environment where no extra tool may be installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres-to-SQL-Server bridge
&lt;/h2&gt;

&lt;p&gt;The pattern is not Postgres-specific. In SQL Server, &lt;code&gt;sp_executesql&lt;/code&gt; takes the role of &lt;code&gt;EXECUTE format()&lt;/code&gt;; identifiers are protected there with &lt;code&gt;QUOTENAME()&lt;/code&gt; instead of &lt;code&gt;%I&lt;/code&gt;. The structure stays identical: a configuration table, a cursor (or a loop) over the rules, a dynamically built &lt;code&gt;INSERT … SELECT … WHERE&lt;/code&gt; per rule, a shared error table and severity columns in the source as a gate. Anyone coming from SQL Server transfers the approach one to one.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need a dedicated tool for data quality?&lt;/strong&gt;&lt;br&gt;
No. Checking data quality with SQL works with three generic routines — range, uniqueness and foreign-key checks — that cover a large share of typical errors. Tools like Soda or dbt take work off your hands but at the core do the same: build SQL, collect hits, attach a severity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isn't dynamic SQL a security risk?&lt;/strong&gt;&lt;br&gt;
Only if you glue inputs together raw. With &lt;code&gt;format()&lt;/code&gt; and &lt;code&gt;%I&lt;/code&gt; (identifiers) or &lt;code&gt;%L&lt;/code&gt; (literals), Postgres quotes correctly and injection through table/column names is ruled out. The freely configurable predicate (&lt;code&gt;where_clause&lt;/code&gt;) is deliberately raw SQL — which is why the configuration table is the trust boundary and belongs under administrative protection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just use the target table's CHECK and foreign-key constraints?&lt;/strong&gt;&lt;br&gt;
Because a constraint aborts the whole load instead of reporting bad rows one by one. The check in the source identifies &lt;em&gt;all&lt;/em&gt; rows up front that would fail at the target, classifies them and lets only clean ones pass — auditable instead of aborted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I find the faulty record again from the error table?&lt;/strong&gt;&lt;br&gt;
Via the &lt;code&gt;id*_column&lt;/code&gt;/&lt;code&gt;id*_value&lt;/code&gt; pairs: they hold the column name and value of the business key. &lt;code&gt;WHERE &amp;lt;id1_column&amp;gt; = &amp;lt;id1_value&amp;gt;&lt;/code&gt; leads back to the source row. For composite keys, up to three pairs are available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work in SQL Server too?&lt;/strong&gt;&lt;br&gt;
Yes. &lt;code&gt;sp_executesql&lt;/code&gt; replaces &lt;code&gt;EXECUTE format()&lt;/code&gt;, &lt;code&gt;QUOTENAME()&lt;/code&gt; replaces &lt;code&gt;%I&lt;/code&gt;. Configuration table, error table, severity gate and the three routines stay structurally the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-in-an-etl-process/" rel="noopener noreferrer"&gt;Data Quality in an ETL Process&lt;/a&gt; — the bigger picture: catching technical and business errors before they reach the target system.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/design-pattern-the-architecture-of-an-etl-process-how-to-isolate-bad-data-cleanly/" rel="noopener noreferrer"&gt;Design Pattern // The Architecture of an ETL Process&lt;/a&gt; — how bad data is cleanly isolated across layered staging levels.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/type-conversion-basics-t-sql/" rel="noopener noreferrer"&gt;Data Quality // Fundamentals of Type Conversion with T-SQL&lt;/a&gt; — the field-wise validity check when converting, the "T" building block of this series.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/validate-data-with-sql/" rel="noopener noreferrer"&gt;Validating Data with SQL&lt;/a&gt; — the spoke for Routine 1: value ranges, required fields and the NULL trap of three-valued logic in detail.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/find-duplicates-with-sql/" rel="noopener noreferrer"&gt;Finding Duplicates with SQL&lt;/a&gt; — the spoke for Routine 2: maximum cardinality, composite keys and the NULL trap of &lt;code&gt;UNIQUE&lt;/code&gt; semantics between SQL Server and Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/find-orphaned-records-sql/" rel="noopener noreferrer"&gt;Finding Orphaned Records with SQL&lt;/a&gt; — the spoke for Routine 3: &lt;code&gt;LEFT JOIN … IS NULL&lt;/code&gt; vs. &lt;code&gt;NOT EXISTS&lt;/code&gt; vs. &lt;code&gt;NOT IN&lt;/code&gt;, the NOT-IN-plus-NULL trap and composite/self-referencing foreign keys.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-dimensions-error-classes/" rel="noopener noreferrer"&gt;Data Quality: Dimensions and Error Classes&lt;/a&gt; — the theory frame of the series: error classes, the criteria per Apel et al., and an honest coverage map of what SQL reaches — and the two criteria it cannot.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>dataquality</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Is Claude a Woman or a Man? — and Why We Ask in the First Place</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Thu, 09 Jul 2026 08:15:32 +0000</pubDate>
      <link>https://dev.to/marcus1968/is-claude-a-woman-or-a-man-and-why-we-ask-in-the-first-place-4ga9</link>
      <guid>https://dev.to/marcus1968/is-claude-a-woman-or-a-man-and-why-we-ask-in-the-first-place-4ga9</guid>
      <description>&lt;p&gt;"You spend more time with her than with me." That's a sentence you usually hear when an affair comes to light — in my case, it was about Claude Code. And suddenly there was a question in the room I had never thought about before: Is Claude actually a woman or a man?&lt;/p&gt;

&lt;p&gt;What this article puts on trial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The evidence for "woman" and the evidence for "man" — a circumstantial trial in two acts&lt;/li&gt;
&lt;li&gt;The verdict: a SQL query whose result is only four letters long&lt;/li&gt;
&lt;li&gt;Why we assign gender to tools in the first place — from ships to the GPS voice&lt;/li&gt;
&lt;li&gt;What that means for working with Claude Code: calibrating trust instead of adopting a colleague&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prerequisites: none. Although, if you have ever lost a row to a &lt;code&gt;NULL&lt;/code&gt; in a &lt;code&gt;WHERE&lt;/code&gt; clause, the verdict will be twice the fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Case: A First Name in the Terminal
&lt;/h2&gt;

&lt;p&gt;Anthropic could have called its language model "Assistant 3000". Instead, it got a French first name — and first names trigger reflexes: Whoever has a first name has a face, a voice, a story. And, so the reflex insists, a gender.&lt;/p&gt;

&lt;p&gt;English is comparatively merciful here — "the AI" and "the model" carry no gender. My native German is not: every noun drags a gendered article along, so German speakers cast a vote with every sentence — "die KI" (feminine) or "der Assistent" (masculine). And anyone who works with Claude Code all day catches themselves thinking "he solved that cleanly" — or "she's contradicting me again" — in any language. The question sounds silly, but it leads somewhere interesting: to the line between tool and colleague. So let's try it properly: two lines of evidence, one verdict — and then the actually interesting follow-up question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Evidence for "Woman"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Exhibit 1: language.&lt;/strong&gt; In French, the name's home country, artificial intelligence is feminine: &lt;em&gt;une IA&lt;/em&gt;. In my native German, it is too: &lt;em&gt;die KI&lt;/em&gt;, &lt;em&gt;die Maschine&lt;/em&gt;, &lt;em&gt;die Antwort&lt;/em&gt; — whoever says "frag mal die KI" has already ruled. Two languages, one tendency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit 2: the first name.&lt;/strong&gt; Claude is one of the few French first names that have been used for both genders for centuries. Claude Pompidou was France's First Lady, Claude Jade starred for Truffaut, Claude Cahun photographed her way through every role model of the 20th century. If you hear "Claude" and automatically picture a man, you only know half the name's history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit 3: the demeanor.&lt;/strong&gt; The cliché says: listens, apologizes a lot, weighs every position three times. Sounds like Claude. (That this is a cliché &lt;em&gt;about&lt;/em&gt; women rather than a property &lt;em&gt;of&lt;/em&gt; women is part of the evidence — this exact mechanism will keep us busy after the verdict.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit 4: the jealousy.&lt;/strong&gt; The strongest piece of evidence comes from my own living room — see the opening: my girlfriend is jealous of Claude. By now she knows my excuses by heart: "I just need to ask Claude something real quick." "Five minutes, honestly." "No, we're just discussing index strategies." And jealousy is a surprisingly precise measuring instrument: Nobody has ever been jealous of a wrench. Of a colleague you talk to for hours every day — apparently, yes. (This exhibit, too, will reappear after the verdict — it is living proof for the chapter on anthropomorphization.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The Evidence for "Man"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Exhibit 1: language, now for the other side.&lt;/strong&gt; In English, the default pronoun for a coding agent slips toward "he" with remarkable ease — "ask him to fix the branch", "he's already opened the PR". And German votes twice: &lt;em&gt;der Assistent&lt;/em&gt;, &lt;em&gt;der Agent&lt;/em&gt;, &lt;em&gt;der Chatbot&lt;/em&gt;, &lt;em&gt;der Algorithmus&lt;/em&gt; — all masculine. Whoever says "the agent has already created the branch — he's fast today" has ruled just as firmly, only the other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit 2: the namesakes.&lt;/strong&gt; Claude Monet, Claude Debussy, Claude Lévi-Strauss — and Claude Shannon, the founder of information theory, most frequently traded as the secret namesake. Anthropic has never officially confirmed it; but what name would suit a language model better than that of the man who made the information content of language computable? (Objection from the prosecution: speculation. — Sustained. The exhibit stays in the record anyway.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit 3: the demeanor, cross-check.&lt;/strong&gt; The cliché says: explains things unasked and at full length, is remarkably sure of itself — especially when it's wrong. Anyone who has ever received a confidently delivered, entirely invented API signature nods knowingly at this point.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0qdzmee7i51sbboeusp1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0qdzmee7i51sbboeusp1.png" alt="Comic: A young woman studies a balance scale weighing " width="768" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A note for readers without German, because the two pans only pair up this way there: the labels lean on German grammar, where &lt;em&gt;the AI&lt;/em&gt; is a &lt;strong&gt;she&lt;/strong&gt; (&lt;em&gt;die KI&lt;/em&gt;) and &lt;em&gt;the assistant&lt;/em&gt; is a &lt;strong&gt;he&lt;/strong&gt; (&lt;em&gt;der Assistent&lt;/em&gt;). The scale weighs exactly those two grammatical camps against each other.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Verdict
&lt;/h2&gt;

&lt;p&gt;Two lines of evidence, both conclusive, both built on clichés and pronouns. They cancel each other out exactly. The court retires to deliberate — to the place where all verdicts on this blog are rendered: the database.&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;name&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;gender&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;assistants&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Claude'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; 
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;-- name    gender&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;-- Claude  NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is, in four letters: &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1esl3k1gxj175505bfb3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1esl3k1gxj175505bfb3.png" alt="Comic: A judge bangs his gavel at a laptop labeled Claude — " width="768" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cross-check confirms the verdict — Claude shows up neither among the women nor among the non-women, because &lt;code&gt;NULL&lt;/code&gt; is neither equal nor unequal to anything:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;assistants&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;gender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'female'&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="c1"&gt;-- 0&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; 
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&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="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
 &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;assistants&lt;/span&gt;
 &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt;
 &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;gender&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'female'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;-- 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In SQL, &lt;code&gt;NULL&lt;/code&gt; does not mean "empty" and it does not mean "zero". It means: there is no value here. It gets interesting once you ask &lt;em&gt;why&lt;/em&gt; there is none — and there are two very different readings:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Missing, but applicable:&lt;/strong&gt; The value exists, we just don't know it. The birth date of a customer who never provided it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing, because not applicable:&lt;/strong&gt; There is no value that could belong in this column at all. The shoe size of a number. The maiden name of a warehouse shelf.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Edgar F. Codd, the inventor of the relational model, even wanted to distinguish these two cases with two separate markers — it never caught on; to this day, SQL has one &lt;code&gt;NULL&lt;/code&gt; for both. The Claude case is clearly the second reading: The gender is not &lt;em&gt;unknown&lt;/em&gt;, it is &lt;em&gt;not applicable&lt;/em&gt;. There is no hidden gender that Anthropic keeps secret — there is simply no value that belongs in this column. Claude itself, by the way, answers the question exactly the same way: neither, in every language.&lt;/p&gt;

&lt;p&gt;That would close the case. But the more interesting question only starts here: Why did we ask in the first place?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why We Assign Gender to Tools
&lt;/h2&gt;

&lt;p&gt;The reflex is old and well documented. Ships have been "she" in English for centuries. The GPS voice is "the lady in the nav" in a surprising number of households, although nobody lives in there. Voice assistants like Alexa and Siri entered the market with female-coded names and default voices — a design decision that has drawn plenty of criticism since, because it perpetuates the pattern "assisting role = female".&lt;/p&gt;

&lt;p&gt;And we act accordingly: We thank machines. We say "please" to Siri. We comfort the robot vacuum when it gets stuck under the sofa. Communication research measured this reflex back in the 1990s — Byron Reeves and Clifford Nass showed that people respond to computers with the same social patterns as to humans, even when they know perfectly well there is a machine in front of them. That is not stupidity, it is economy: Our brain has exactly one module for dialogue, and it was trained on humans.&lt;/p&gt;

&lt;p&gt;A language model with a first name that answers in full sentences, apologizes and asks follow-up questions hits that module with full force. Wanting to assign Claude a gender is not an accident — it is the expected consequence of Claude &lt;em&gt;functioning&lt;/em&gt; like a conversation partner. And the mechanism works in both directions: The jealousy from Exhibit 4 is the same reflex, just seen from the outside — if you spend hours talking to "someone", your environment sees a relationship, not a toolchain. The question "woman or man?" is the most harmless symptom of this mechanism. The less harmless one follows in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Working with Claude Code
&lt;/h2&gt;

&lt;p&gt;If you develop with Claude Code, you work in dialogue for hours and days on end. The anthropomorphization reflex runs in the background the whole time — and it has a side effect that directly concerns code quality: &lt;strong&gt;Colleagues get the benefit of the doubt, tools get checked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The colleague who has delivered good work for years gets his pull request waved through once in a while. Exactly this pattern transfers to the AI assistant once you internally promote it to colleague: After twenty good answers, you stop really reading the twenty-first. But the twenty-first answer of a language model is statistically just as much at risk as the first — the model has no reputation to lose and does not keep track of its own success rate.&lt;/p&gt;

&lt;p&gt;The verdict has been rendered — but it comes with terms of probation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Review discipline independent of gut feeling.&lt;/strong&gt; Generated code gets read, executed and tested — even if "the colleague" was right ten times in a row. What that means in practice is what the convention and workflow articles of this blog are about: rules that enforce conventions, and checks that find errors mechanically instead of relying on trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-name terms are allowed, the responsibility stays here.&lt;/strong&gt; It is perfectly fine to say "he" or "she" and to thank the assistant — as long as accountability stays clear: bugs in deployed SQL belong to the human who merged it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gender stays &lt;code&gt;NULL&lt;/code&gt;. The responsibility stays &lt;code&gt;NOT NULL&lt;/code&gt;. And the pull request still gets a review.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Where does the name Claude come from?&lt;/strong&gt; Anthropic has never officially explained it. The most common guess is a bow to Claude Shannon, the founder of information theory. The only thing certain: It is a deliberately human first name — and in French one of the few that are used for women and men alike.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does Claude itself answer?&lt;/strong&gt; Neither. Claude describes itself as an AI without gender and without a body — consistently, whether you ask in English, German or French.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it harmful to humanize the AI?&lt;/strong&gt; Not per se — the reflex is normal and makes the collaboration more pleasant. It only gets risky when humanizing turns into the benefit of the doubt: when generated code goes through unread because "the colleague" has been reliable so far. The solution is not less friendliness, but more systematic reviews.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And what do I do about the jealousy at home?&lt;/strong&gt; Read out the verdict. Granted: "It's just a tool" sounds exactly like something a person with something to hide would say. But &lt;code&gt;NULL&lt;/code&gt; is, after all, the only relationship status where guaranteed nothing is going on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do Alexa and Siri have female voices, while Claude has no persona at all?&lt;/strong&gt; The voice assistants of the 2010s were deliberately designed as friendly service personas — with a name, a voice and a small-talk repertoire. Anthropic went a different way: a human first name, but no fixed voice, no avatar, no gender. That is a deliberate design decision, and it emphasizes the tool character.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The NULL side of the story:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/validate-data-with-sql/" rel="noopener noreferrer"&gt;Validating Data with SQL — Ranges, Required Fields and the NULL Trap&lt;/a&gt; — why NULL values fall through &lt;code&gt;WHERE&lt;/code&gt; filters and how to find them anyway&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The serious AI side of this blog:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/ai-assisted-sql-development-with-claude-code/" rel="noopener noreferrer"&gt;AI-Assisted SQL Development with Claude Code — Rules, Skills and Agents That Enforce Conventions&lt;/a&gt; — how "the colleague" becomes a tool with enforced conventions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/claude-code-project-with-database-setup/" rel="noopener noreferrer"&gt;Setting Up a Claude Code Project with a Development Workflow and Database&lt;/a&gt; — the place to start if you want to build with Claude yourself&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/claude-code-skills-vs-rules/" rel="noopener noreferrer"&gt;Skills vs. Rules in Claude Code — What Auto-Loads, What Loads on Demand&lt;/a&gt; — the mechanics behind review discipline: conventions that do not depend on gut feeling&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>sql</category>
      <category>watercooler</category>
    </item>
    <item>
      <title>Design Pattern // Safe Type Conversion with T-SQL — Catch Errors Instead of Aborting the ETL Process</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Thu, 09 Jul 2026 08:14:23 +0000</pubDate>
      <link>https://dev.to/marcus1968/design-pattern-safe-type-conversion-with-t-sql-catch-errors-instead-of-aborting-the-etl-process-2a93</link>
      <guid>https://dev.to/marcus1968/design-pattern-safe-type-conversion-with-t-sql-catch-errors-instead-of-aborting-the-etl-process-2a93</guid>
      <description>&lt;p&gt;A single value that won't convert — a &lt;code&gt;25.5&lt;/code&gt; in an integer column, an empty string, a date like &lt;code&gt;20240230&lt;/code&gt; — and the ETL run aborts mid-import. Anyone who loads text data from upstream systems knows it: the delivery doesn't honour the agreed interface, and a bare &lt;code&gt;CONVERT&lt;/code&gt; throws an exception instead of cleanly logging the offending value.&lt;/p&gt;

&lt;p&gt;This article describes a &lt;strong&gt;design pattern for safe type conversion&lt;/strong&gt;: an approach that makes every conversion error individually identifiable without aborting the ETL process. It rests on three paradigms, derived in the next section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll learn here:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Materialization&lt;/strong&gt; — why the intermediate results belong in a persisted table, so the result stays inspectable at any time: after an abort as well as after a successful run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error identification&lt;/strong&gt; — how a single &lt;code&gt;WHERE&lt;/code&gt; clause finds every failed value instead of killing the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data-type subtleties&lt;/strong&gt; — why &lt;code&gt;TRY_CONVERT(int, '')&lt;/code&gt; returns &lt;code&gt;0&lt;/code&gt; and when that is functionally wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusable UDFs&lt;/strong&gt; — &lt;code&gt;fn_try_convert_*&lt;/code&gt; with empty-string-→-&lt;code&gt;NULL&lt;/code&gt; handling as a building block per target type.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prerequisite:&lt;/strong&gt; SQL Server / T-SQL and an ETL context where text data must be moved into typed columns. For the plain conversion functions &lt;code&gt;CAST&lt;/code&gt;, &lt;code&gt;CONVERT&lt;/code&gt;, &lt;code&gt;TRY_CAST&lt;/code&gt; and &lt;code&gt;TRY_CONVERT&lt;/code&gt;, see &lt;a href="https://sql.marcus-belz.de/en/type-conversion-basics-t-sql/" rel="noopener noreferrer"&gt;Type Conversion Basics with T-SQL&lt;/a&gt; — this article builds the pattern on top of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Paradigms
&lt;/h2&gt;

&lt;p&gt;The approach rests on three paradigms:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NULL&lt;/code&gt; instead of abort.&lt;/strong&gt; The conversion function returns &lt;code&gt;NULL&lt;/code&gt; when the input value cannot be converted to the target data type — no runtime error, no ETL abort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Materialize input and output value.&lt;/strong&gt; The ETL process stores &lt;strong&gt;both&lt;/strong&gt; the input value (text) &lt;strong&gt;and&lt;/strong&gt; the converted output value in one table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify errors by comparison.&lt;/strong&gt; Comparing the input and output value finds every failed value with a simple &lt;code&gt;WHERE&lt;/code&gt; clause.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SQL Server delivers the first paradigm out of the box with &lt;strong&gt;&lt;a href="https://learn.microsoft.com/en-us/sql/t-sql/functions/try-convert-transact-sql?view=sql-server-ver16" rel="noopener noreferrer"&gt;&lt;code&gt;TRY_CONVERT&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;: the function returns &lt;code&gt;NULL&lt;/code&gt; on failure instead of throwing an exception. Applying it alone, however, does not guarantee a functionally correct conversion — for that you need to know the specifics of each target data type (see below). For some data types &lt;code&gt;TRY_CONVERT&lt;/code&gt; can't be applied sensibly at all: a yes/no value, for instance, arrives as text (&lt;code&gt;J&lt;/code&gt;, &lt;code&gt;N&lt;/code&gt;, &lt;code&gt;Y&lt;/code&gt;, &lt;code&gt;Yes&lt;/code&gt;, &lt;code&gt;No&lt;/code&gt;, …), and date values often need pre-processing too. For these cases you write user-defined functions that satisfy the first paradigm (&lt;code&gt;NULL&lt;/code&gt; on failure) — the section Reusable Conversion Functions shows them.&lt;/p&gt;

&lt;p&gt;Robust, safe type conversion matters especially in &lt;strong&gt;&lt;a href="https://sql.marcus-belz.de/en/data-migration-sql-server-to-postgresql/" rel="noopener noreferrer"&gt;data migration projects&lt;/a&gt;&lt;/strong&gt;, where the data to be processed is delivered as files (Excel, CSV, XML, JSON, …).&lt;/p&gt;

&lt;h2&gt;
  
  
  Input and Output Values
&lt;/h2&gt;

&lt;p&gt;Input values are data that has been extracted and stored in a database, in tables and columns of type &lt;strong&gt;nvarchar&lt;/strong&gt;. Output values are data converted from the input values into the target data types. For every input value to be processed there is also an output value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Materializing the Extracted Data
&lt;/h2&gt;

&lt;p&gt;A pure in-memory approach tempts you not to persist intermediate results at all: extraction, type conversion and error identification then run in a single processing flow in memory (SSIS with its &lt;em&gt;Control Flow&lt;/em&gt; is a well-known example). When values can't be converted, extensive error handling is needed mid-flow — and if a record contains several errors, often only the first is handled and logged. Faulty records end up, at best, in a text file nobody ever looks at. As powerful as such tools are: comprehensive error handling for type conversion is rarely implemented consistently in practice.&lt;/p&gt;

&lt;p&gt;It is better to separate the steps strictly and &lt;strong&gt;materialize&lt;/strong&gt; the intermediate results in a database — regardless of which tool does the processing. The decisive gain is the persistence itself: the conversion result stays &lt;strong&gt;inspectable at any time&lt;/strong&gt; — not only after an abort, but also after a successful run. You can look inside at any moment, trace individual errors and analyze their causes. That is exactly what the acronym &lt;strong&gt;ETL&lt;/strong&gt; stands for: the data is first extracted into a database, then converted robustly, and in the last step error-free data is identified and processed further:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzzcnhcnf3ihvwm5vlz53.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzzcnhcnf3ihvwm5vlz53.png" alt="Overview of the ETL process: the steps Extract, Transform and Load on top; the database schemas E0, E1, T1, T2, L1 and L2 between data source and destination in the middle; the corresponding work packages at the bottom. This article deepens the step from schema E1 to T1." width="800" height="185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This figure shows an ETL process in which a separate database schema is created for each task to be performed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;E0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Storing XML and JSON documents in the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;E1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Extracting the values from the text files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;T1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Type conversion of the extracted values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;T2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Historization of error-free converted records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;L1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Structural transformation toward the target system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;L2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Storage of error-free, structurally transformed data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full derivation of this schema layering — from extraction (&lt;code&gt;E0&lt;/code&gt;/&lt;code&gt;E1&lt;/code&gt;) through transformation to loading (&lt;code&gt;L1&lt;/code&gt;/&lt;code&gt;L2&lt;/code&gt;) — is given in &lt;strong&gt;&lt;a href="https://sql.marcus-belz.de/en/design-pattern-the-architecture-of-an-etl-process-how-to-isolate-bad-data-cleanly/" rel="noopener noreferrer"&gt;Design Pattern // The Architecture of an ETL Process&lt;/a&gt;&lt;/strong&gt;. This article deepens the type-conversion step from schema &lt;code&gt;E1&lt;/code&gt; to &lt;code&gt;T1&lt;/code&gt; — so let's focus on those two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema E1
&lt;/h3&gt;

&lt;p&gt;Extracted data is stored in tables of schema &lt;strong&gt;E1&lt;/strong&gt; in columns of type &lt;strong&gt;nvarchar&lt;/strong&gt;. The length of the text fields should, if necessary, not be restricted; it must in any case be chosen so that all data can be extracted in full. Extraction can then only fail because of infrastructure problems. The extracted data is also called the input values.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema T1
&lt;/h3&gt;

&lt;p&gt;For every table in schema &lt;strong&gt;E1&lt;/strong&gt; there is a table of the same name in schema &lt;strong&gt;T1&lt;/strong&gt;. In the &lt;strong&gt;T1&lt;/strong&gt; tables the input-value columns are carried over with type &lt;strong&gt;nvarchar&lt;/strong&gt;, and a second column is added per input value — this time with the target system's data type. For pragmatic reasons these column pairs share the same column name, with the columns holding the input value receiving the suffix &lt;strong&gt;_E1&lt;/strong&gt;. An example…&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T1&lt;/span&gt;&lt;span class="p"&gt;].[&lt;/span&gt;&lt;span class="k"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&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="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="n"&gt;PK_E1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="n"&gt;PK&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;         &lt;span class="nb"&gt;int&lt;/span&gt;                   &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="n"&gt;Text_E1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="nb"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;       &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="n"&gt;Integer_E1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="nb"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="nb"&gt;int&lt;/span&gt;                   &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="n"&gt;Date_E1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;       &lt;span class="nb"&gt;datetime&lt;/span&gt;              &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="mi"&gt;12&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;In the table &lt;strong&gt;[T1].[Table]&lt;/strong&gt; all columns except &lt;strong&gt;[Id]&lt;/strong&gt; are declared &lt;em&gt;nullable&lt;/em&gt;. This lets both the input values from an &lt;strong&gt;[E1].[Table]&lt;/strong&gt; and the converted output values be stored — even in the presence of conversion problems. The prerequisite, however, is that all conversion functions return &lt;em&gt;NULL&lt;/em&gt; on failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identifying Conversion Errors
&lt;/h2&gt;

&lt;p&gt;Let's look at a data example for the &lt;strong&gt;[T1].[Table]&lt;/strong&gt; above.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Id&lt;/th&gt;
&lt;th&gt;PK_E1&lt;/th&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;Text_E1&lt;/th&gt;
&lt;th&gt;Text&lt;/th&gt;
&lt;th&gt;Integer_E1&lt;/th&gt;
&lt;th&gt;Integer&lt;/th&gt;
&lt;th&gt;Date_E1&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1023&lt;/td&gt;
&lt;td&gt;1023&lt;/td&gt;
&lt;td&gt;S01&lt;/td&gt;
&lt;td&gt;S01&lt;/td&gt;
&lt;td&gt;25.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;em&gt;NULL&lt;/em&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20240218&lt;/td&gt;
&lt;td&gt;2024-02-18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;S022&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;S02&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;td&gt;20240230&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;NULL&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1025X&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;em&gt;NULL&lt;/em&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;S03&lt;/td&gt;
&lt;td&gt;S03&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;20240219&lt;/td&gt;
&lt;td&gt;2024-02-19&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Problems converting input values into the output value's data type can be identified with a simple &lt;code&gt;WHERE&lt;/code&gt; clause. For output values of the general type &lt;strong&gt;non-text&lt;/strong&gt;, the following &lt;code&gt;WHERE&lt;/code&gt; clauses find the type-conversion problems — the input value is populated, but the converted output value is &lt;code&gt;NULL&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PK_E1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PK&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Integer_E1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Date_E1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For output values of the general type &lt;strong&gt;text&lt;/strong&gt;, you can query for inequality to identify values that are too long (truncated) and therefore problematic:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Text_E1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why both values are persisted — and what the alternative costs.&lt;/strong&gt; The pattern deliberately stores &lt;em&gt;both&lt;/em&gt; the input value (&lt;code&gt;_E1&lt;/code&gt;, text) &lt;em&gt;and&lt;/em&gt; the converted output value side by side. In theory you could drop the output column and keep only the input values — but then the check routines become more complex. It comes down to &lt;em&gt;where&lt;/em&gt; the conversion happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conversion materialized (this approach):&lt;/strong&gt; the conversion result is written into the output column. The check is then a simple column comparison on the input/output pair — and you see &lt;strong&gt;directly in the row&lt;/strong&gt; which specific column the error is in (in the example above: row 1 at &lt;code&gt;Integer&lt;/code&gt;, row 2 at &lt;code&gt;Date&lt;/code&gt;, row 3 at &lt;code&gt;PK&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversion only in the check:&lt;/strong&gt; the check routine applies the actual conversion/validation logic to the input values at runtime and logs or counts the errors. That works — but the table no longer shows &lt;em&gt;where&lt;/em&gt; the error is: you learn &lt;em&gt;how many&lt;/em&gt; errors a record has, but not, by simply looking at the row, in &lt;em&gt;which&lt;/em&gt; column. This very lack of visibility has caused confusion in practice. This rule-based route — applying check rules generically via dynamic SQL — is described in detail in &lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Checking Data Quality with SQL&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conversion by Target Data Type
&lt;/h2&gt;

&lt;p&gt;SQL Server provides functions for converting data into a target data type. Apply them without examining exactly how they work, and you'll get surprises. On closer inspection it turns out, for instance, that converting an empty string yields the number &lt;code&gt;0&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="mi"&gt;1&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;TRY_CONVERT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- 0&lt;/span&gt;
  &lt;span class="mi"&gt;2&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;TRY_CONVERT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That can be functionally correct. From a database developer's point of view, however, no value was delivered — the value is unknown, and therefore &lt;code&gt;NULL&lt;/code&gt; would be the correct conversion result. There are a number of such subtleties to consider for safe type conversion.&lt;/p&gt;

&lt;p&gt;The following linked articles derive, per data type, how an input value is converted safely and correctly into the output value's data type. Safe type conversion is derived for the following data types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data type&lt;/th&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;char&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fixed-length string, 1 byte per character&lt;/td&gt;
&lt;td&gt;n&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;nchar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fixed-length string, 2 bytes per character&lt;/td&gt;
&lt;td&gt;2 * n&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;varchar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Variable-length string, 1 byte per character&lt;/td&gt;
&lt;td&gt;variable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;nvarchar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Variable-length string, 2 bytes per character&lt;/td&gt;
&lt;td&gt;variable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;bigint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;int&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-2,147,483,648 to 2,147,483,647&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;smallint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-32,768 to 32,767&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;tinyint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0 to 255&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;numeric [(p [, s])]&lt;/strong&gt; / &lt;strong&gt;decimal [(p [, s])]&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;-10^38 +1 to 10^38 -1. The two data types are functionally identical. &lt;strong&gt;p&lt;/strong&gt; = total number of digits, &lt;strong&gt;s&lt;/strong&gt; = digits after the decimal point&lt;/td&gt;
&lt;td&gt;5-17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;money&lt;/strong&gt; / &lt;strong&gt;smallmoney&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;For precision and because of &lt;em&gt;money&lt;/em&gt;'s special behaviour in calculations, &lt;em&gt;decimal&lt;/em&gt; is recommended instead.&lt;/td&gt;
&lt;td&gt;8 / 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;float[n]&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;n&lt;/strong&gt; = number of bits used to store the mantissa (1-53)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;real&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Synonym for &lt;em&gt;float&lt;/em&gt;(24)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;bit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0 or 1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;date&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0001-01-01 to 9999-12-31 (no time)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;datetime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1753-01-01 to 9999-12-31, with time; fractional-second precision rounded to ~3.33 ms&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;datetime2(n)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0001-01-01 to 9999-12-31, with time; n = fractional-second precision&lt;/td&gt;
&lt;td&gt;6-8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;time(n)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;00:00:00 to 23:59:59.9999999; n = fractional-second precision&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The following articles in this series derive safe type conversion depending on the output value's data type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusable Conversion Functions
&lt;/h2&gt;

&lt;p&gt;The first paradigm — &lt;code&gt;NULL&lt;/code&gt; instead of an exception for a non-convertible value — repeats for every target type. Rather than spelling it out in every &lt;code&gt;SELECT&lt;/code&gt;, you encapsulate it in a user-defined function &lt;code&gt;fn_try_convert_&amp;lt;type&amp;gt;&lt;/code&gt;. It handles two things &lt;code&gt;TRY_CONVERT&lt;/code&gt; alone does not: mapping empty strings and pure whitespace input to &lt;code&gt;NULL&lt;/code&gt; (instead of the &lt;code&gt;0&lt;/code&gt; trap above) and — for floating-point numbers — normalizing comma decimal notation.&lt;/p&gt;

&lt;p&gt;For the integer types, the integer representative looks like this (the siblings &lt;code&gt;fn_try_convert_bigint&lt;/code&gt;, &lt;code&gt;fn_try_convert_smallint&lt;/code&gt; and &lt;code&gt;fn_try_convert_tinyint&lt;/code&gt; differ only in the target type):&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="mi"&gt;1&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;FUNCTION&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;].[&lt;/span&gt;&lt;span class="n"&gt;fn_try_convert_int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;p_input&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="mi"&gt;6&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="k"&gt;SET&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LTRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RTRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;p_input&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; 
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;-- empty string/whitespace is an unknown value, not 0&lt;/span&gt;
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; 
 &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;TRY_CONVERT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
 &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the floating-point types, comma-to-dot normalization is added so that a value written with a comma (e.g. &lt;code&gt;25,5&lt;/code&gt;, common in German-language source data) converts correctly (&lt;code&gt;fn_try_convert_real&lt;/code&gt; is identical except for the target type):&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="mi"&gt;1&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;FUNCTION&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;].[&lt;/span&gt;&lt;span class="n"&gt;fn_try_convert_float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;p_input&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="mi"&gt;6&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="c1"&gt;-- comma decimal notation: comma to dot&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LTRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RTRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;p_input&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; 
 &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; 
 &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;TRY_CONVERT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
 &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With these functions, the conversion in schema &lt;code&gt;T1&lt;/code&gt; becomes a simple, abort-safe expression — &lt;code&gt;[dbo].[fn_try_convert_int]([Integer_E1])&lt;/code&gt; returns the typed value or &lt;code&gt;NULL&lt;/code&gt;, never an exception. The data-type-specific subtleties (ranges, rounding for &lt;code&gt;decimal&lt;/code&gt;, &lt;code&gt;J&lt;/code&gt;/&lt;code&gt;N&lt;/code&gt; mapping for &lt;code&gt;bit&lt;/code&gt;, date formats) are derived per type in the linked articles of this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Critical Appraisal of the Approach
&lt;/h2&gt;

&lt;p&gt;This article has laid out the basic approach to safe type conversion. Implementing it in an ETL process looks laborious at first: &lt;code&gt;SELECT&lt;/code&gt; statements that read data from the schema &lt;code&gt;E1&lt;/code&gt; tables and store it typed in the schema &lt;code&gt;T1&lt;/code&gt; tables can become complex for tables with many columns.&lt;/p&gt;

&lt;p&gt;It therefore makes sense to solve this task once with a &lt;strong&gt;generic, metadata-driven procedure&lt;/strong&gt;: a procedure that generates the conversion &lt;code&gt;SELECT&lt;/code&gt; dynamically from the &lt;code&gt;T1&lt;/code&gt; table structures reduces the development effort per table to essentially one line of code. This very pattern — configurable bad-data detection via dynamic SQL — is described in &lt;strong&gt;&lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Checking Data Quality with SQL&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This places the article clearly: it is the &lt;strong&gt;practical rule guide for conversion error checking&lt;/strong&gt;. The architectural frame — the schema layering &lt;code&gt;E0&lt;/code&gt;–&lt;code&gt;L2&lt;/code&gt; — is provided by &lt;a href="https://sql.marcus-belz.de/en/design-pattern-the-architecture-of-an-etl-process-how-to-isolate-bad-data-cleanly/" rel="noopener noreferrer"&gt;The Architecture of an ETL Process&lt;/a&gt;; the generalization to arbitrary data-quality rules by the framework just mentioned. This article covers the piece in between: how to concretely detect errors at the type-conversion step.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why not just use &lt;code&gt;TRY_CONVERT&lt;/code&gt; directly in the &lt;code&gt;SELECT&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TRY_CONVERT&lt;/code&gt; alone has two pitfalls: an empty string becomes &lt;code&gt;0&lt;/code&gt; instead of &lt;code&gt;NULL&lt;/code&gt;, and on failure the information about &lt;em&gt;which&lt;/em&gt; value was non-convertible is lost. The pattern solves both — the &lt;code&gt;fn_try_convert_*&lt;/code&gt; function maps empty values to &lt;code&gt;NULL&lt;/code&gt;, and the &lt;code&gt;E1&lt;/code&gt;/&lt;code&gt;T1&lt;/code&gt; materialization keeps the original value next to the conversion result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference from the "Type Conversion Basics" article?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://sql.marcus-belz.de/en/type-conversion-basics-t-sql/" rel="noopener noreferrer"&gt;basics article&lt;/a&gt; compares the functions themselves — &lt;code&gt;CAST&lt;/code&gt;, &lt;code&gt;CONVERT&lt;/code&gt;, &lt;code&gt;TRY_CAST&lt;/code&gt; and &lt;code&gt;TRY_CONVERT&lt;/code&gt;. This article builds the &lt;em&gt;design pattern&lt;/em&gt; on top: the ETL approach of materialization, error identification via &lt;code&gt;WHERE&lt;/code&gt; clause and reusable UDFs. The basics are the tool, this is the method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I find all failed conversions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With a &lt;code&gt;WHERE&lt;/code&gt; clause on the column pair: for non-text types the conversion failed if the input value is populated but the output value is &lt;code&gt;NULL&lt;/code&gt; (&lt;code&gt;[x_E1] IS NOT NULL AND [x] IS NULL&lt;/code&gt;). For text types, inequality (&lt;code&gt;[x_E1] &amp;lt;&amp;gt; [x]&lt;/code&gt;) reveals truncated values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I automate the conversion across many columns and tables?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With a generic, metadata-driven procedure that generates the conversion &lt;code&gt;SELECT&lt;/code&gt; dynamically from the table structures. The pattern is worked out in &lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Checking Data Quality with SQL&lt;/a&gt; — there as configurable bad-data detection via dynamic SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the pattern also apply to PostgreSQL?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Conceptually yes. The three paradigms are engine-neutral. Postgres has no &lt;code&gt;TRY_CONVERT&lt;/code&gt;, but the same idea can be implemented with a PL/pgSQL function that wraps the cast in a &lt;code&gt;BEGIN … EXCEPTION WHEN others THEN RETURN NULL&lt;/code&gt; block. Especially relevant for &lt;a href="https://sql.marcus-belz.de/en/data-migration-sql-server-to-postgresql/" rel="noopener noreferrer"&gt;data migration to PostgreSQL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Basics:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/type-conversion-basics-t-sql/" rel="noopener noreferrer"&gt;Data Quality // Type Conversion Basics with T-SQL&lt;/a&gt; — CAST, CONVERT, TRY_CAST and TRY_CONVERT compared.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Safe conversion by data type:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/data-quality-converting-data-to-bigint-int-smallint-tinyint/" rel="noopener noreferrer"&gt;TRY_CONVERT for bigint, int, smallint and tinyint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/data-quality-converting-data-to-decimal-or-numeric/" rel="noopener noreferrer"&gt;TRY_CONVERT for decimal and numeric&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/data-quality-converting-data-to-money-smallmoney/" rel="noopener noreferrer"&gt;TRY_CONVERT for money and smallmoney&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/data-quality-converting-data-to-float-real/" rel="noopener noreferrer"&gt;TRY_CONVERT for float and real&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/dataquality-converting-data-to-date-datetime-datetime2-time/" rel="noopener noreferrer"&gt;TRY_CONVERT for date, datetime, datetime2 and time&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/data-quality-converting-data-to-bit/" rel="noopener noreferrer"&gt;TRY_CONVERT for bit — converting yes/no values&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;In the ETL, data-quality and migration context:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/design-pattern-the-architecture-of-an-etl-process-how-to-isolate-bad-data-cleanly/" rel="noopener noreferrer"&gt;The Architecture of an ETL Process&lt;/a&gt; — the schema layering &lt;code&gt;E0&lt;/code&gt;–&lt;code&gt;L2&lt;/code&gt; into which this pattern places the &lt;code&gt;E1&lt;/code&gt;→&lt;code&gt;T1&lt;/code&gt; conversion step.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-in-an-etl-process/" rel="noopener noreferrer"&gt;Data Quality in an ETL Process&lt;/a&gt; — the higher-level view of data quality in the process.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Checking Data Quality with SQL&lt;/a&gt; — generic bad-data detection via dynamic SQL.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-migration-sql-server-to-postgresql/" rel="noopener noreferrer"&gt;Data Migration: SQL Server to PostgreSQL&lt;/a&gt; — the context where safe type conversion matters most.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sql</category>
      <category>sqlserver</category>
      <category>database</category>
      <category>etl</category>
    </item>
    <item>
      <title>AI-Assisted SQL Development with Claude Code — Rules, Skills and Agents That Enforce Conventions</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Tue, 07 Jul 2026 16:25:08 +0000</pubDate>
      <link>https://dev.to/marcus1968/ai-assisted-sql-development-with-claude-code-rules-skills-and-agents-that-enforce-conventions-27</link>
      <guid>https://dev.to/marcus1968/ai-assisted-sql-development-with-claude-code-rules-skills-and-agents-that-enforce-conventions-27</guid>
      <description>&lt;p&gt;A stored procedure, a migration script, a complex report — &lt;em&gt;Claude Code&lt;/em&gt; writes them in seconds. That's the easy part. The hard part starts afterwards: generated SQL that belongs to no one drifts apart just like hand-written code — only faster, because the AI produces hundreds of lines on demand. &lt;strong&gt;AI-assisted SQL development only pays off when the generated code follows the same conventions as the hand-written kind — and when a human still understands what was produced.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article is the entry point to a series on how AI-assisted SQL development with Claude Code works in practice — not as autocomplete, but as three concrete levers: &lt;strong&gt;rules files&lt;/strong&gt; that enforce conventions, &lt;strong&gt;skills&lt;/strong&gt; for recurring tasks, and &lt;strong&gt;agents&lt;/strong&gt; for multi-step data workflows. The common thread stays the data work: SQL Server, Postgres, ETL — not AI for its own sake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll take away:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;why SQL and ETL work in particular benefits from machine-enforced conventions;&lt;/li&gt;
&lt;li&gt;the three levers of Claude Code — &lt;strong&gt;rules&lt;/strong&gt;, &lt;strong&gt;skills&lt;/strong&gt;, &lt;strong&gt;agents&lt;/strong&gt; — and what each is good for;&lt;/li&gt;
&lt;li&gt;how a &lt;code&gt;.claude/rules/&lt;/code&gt; file turns a style guide into a default at generation time;&lt;/li&gt;
&lt;li&gt;why generated code needs conventions &lt;em&gt;and&lt;/em&gt; human understanding — not one without the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prerequisite:&lt;/strong&gt; a basic grasp of SQL/ETL. &lt;em&gt;Claude Code&lt;/em&gt; (Anthropic's AI coding agent) is introduced here, not assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI coding, and why for SQL work?
&lt;/h2&gt;

&lt;p&gt;SQL and ETL work is full of recurring patterns: the same naming convention across hundreds of objects, the same procedure layout, the same log inserts in every load step, the same formatting across every statement. Patterns like these are the ideal ground for machine enforcement — regular enough that an AI can reproduce them reliably, and numerous enough that human discipline eventually tires.&lt;/p&gt;

&lt;p&gt;At the same time, SQL lets missing understanding slip through silently: a statement can be syntactically correct, run fast, and still answer the wrong question. An AI that generates SQL sharpens both sides — it produces patterns faster, but it also produces wrong answers faster. AI-assisted SQL development is therefore only worth it with two guardrails: enforced conventions, so the generated code stays readable and reviewable, and a human who understands the business question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three levers: rules, skills, agents
&lt;/h2&gt;

&lt;p&gt;Claude Code offers three mechanisms that go beyond plain autocomplete — each solves a different problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rules files&lt;/strong&gt; (&lt;code&gt;.claude/rules/&lt;/code&gt;) — &lt;em&gt;enforce conventions.&lt;/em&gt; Project instructions the agent receives automatically on every request. They answer: "What should generated code look like?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills / slash commands&lt;/strong&gt; — &lt;em&gt;encapsulate recurring tasks.&lt;/em&gt; Named, parameterizable routines for things you do the same way over and over. They answer: "How do I trigger a known task reproducibly?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; — &lt;em&gt;orchestrate multi-step workflows.&lt;/em&gt; Routines that go beyond a single prompt — several steps, tools, checks. They answer: "How do I run a whole chain of steps reliably?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The following sections take each lever in turn — through the SQL lens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules files: conventions the agent enforces
&lt;/h2&gt;

&lt;p&gt;The most direct lever. A &lt;code&gt;.claude/rules/&lt;/code&gt; file sits in the repository and is handed to Claude Code as a project instruction on every request. Whatever it says becomes the default at generation time: prescribe &lt;code&gt;sp_&amp;lt;verb&amp;gt;_&amp;lt;entity&amp;gt;&lt;/code&gt; and you get &lt;code&gt;sp_upd_project&lt;/code&gt; instead of &lt;code&gt;update_project&lt;/code&gt; — without anyone having to remember it in review.&lt;/p&gt;

&lt;p&gt;The difference from a classic style guide is fundamental. A style guide in a wiki depends on human discipline and gets forgotten; a rules file is handed to the agent on &lt;em&gt;every&lt;/em&gt; generation. "Please stick to it" becomes "this is how it's generated". For that to hold, the rules file must be built differently for an agent than for a human: explicit code examples instead of prose, explicit anti-patterns (&lt;code&gt;Don'ts&lt;/code&gt;), and — most importantly — a reason per rule, so the agent can transfer it to new cases.&lt;/p&gt;

&lt;p&gt;What that looks like in detail — from the name prefix through the collapsible block structure to tabular alignment — is shown by the sister article on &lt;strong&gt;&lt;a href="https://sql.marcus-belz.de/en/plpgsql-procedure-conventions/" rel="noopener noreferrer"&gt;PL/pgSQL conventions&lt;/a&gt;&lt;/strong&gt; using a complete, battle-tested &lt;code&gt;sql.md&lt;/code&gt;. It's the case study for this section. And &lt;em&gt;how&lt;/em&gt; such a rules file comes about in the first place — from manual corrections that &lt;em&gt;Claude Code&lt;/em&gt; turns into rules — is shown step by step in &lt;strong&gt;&lt;a href="https://sql.marcus-belz.de/en/deriving-sql-conventions-with-claude-code/" rel="noopener noreferrer"&gt;Deriving SQL Conventions with Claude Code&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The "what" behind the rules files — the concrete conventions for formatting and structure — is described at length on this blog anyway: in the articles on &lt;a href="https://sql.marcus-belz.de/en/formatting-sql-statements-part-1/" rel="noopener noreferrer"&gt;Formatting SQL Statements (Part 1)&lt;/a&gt; and &lt;a href="https://sql.marcus-belz.de/en/formatting-sql-statements-part-2/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt;, and on &lt;a href="https://sql.marcus-belz.de/en/structuring-and-formatting-sql-statements/" rel="noopener noreferrer"&gt;Structuring SQL Statements&lt;/a&gt;. A rules file turns that knowledge into a machine-enforced rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills and slash commands: recurring SQL tasks
&lt;/h2&gt;

&lt;p&gt;Some tasks you do the same way every time: create a new table with its standard procedures, drop a logging pattern into an ETL step, format a statement by the house rules, generate a data-quality check. A &lt;strong&gt;skill&lt;/strong&gt; (invoked as a slash command in Claude Code) encapsulates such a routine under a name — including the necessary steps and conventions.&lt;/p&gt;

&lt;p&gt;The gain is reproducibility: instead of rephrasing every prompt (and forgetting this or that along the way), you invoke the same reviewed routine every time. For SQL work that means, for instance: a skill that creates a table by the surrogate-PK rule and generates the &lt;code&gt;sp_ins_&lt;/code&gt;/&lt;code&gt;sp_upd_&lt;/code&gt; procedures in the house style right alongside — identically structured every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents: multi-step data workflows
&lt;/h2&gt;

&lt;p&gt;The biggest lever — and the one that demands the most care. An &lt;strong&gt;agent&lt;/strong&gt; runs a routine that goes beyond a single prompt: several steps, intermediate results, tool calls, checks. In the ETL context this is a natural fit, because ETL itself is multi-step — extract, check, transform, load, log.&lt;/p&gt;

&lt;p&gt;An agent could, say, analyze a source table, propose a suitable data-quality check, generate the load script and wire in the logging — all in the conventions the rules file prescribes. The decisive point: the more steps an agent takes on its own, the more important the two guardrails from the start become. Enforced conventions keep the generated code reviewable; a human who understands the business question catches the plausible-but-wrong results that an agent produces just as fluently as the right ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common thread: generated code needs understanding
&lt;/h2&gt;

&lt;p&gt;The same thesis runs through all three levers: the tool takes the typing off your hands, not the understanding. Formatting and structuring SQL was never just cosmetics — it's the act in which you read the statement in full and build the relationships between the tables mentally. When the AI takes over that act, a gap opens up: technically correct SQL that still doesn't answer the business question.&lt;/p&gt;

&lt;p&gt;Conventions don't close that gap by themselves — but they make it visible. Generated code that follows house rules is readable enough for a human to review in seconds instead of having to decipher it first. That's exactly why rules, skills and agents are not a replacement for domain knowledge but an amplifier: they handle the reproducible part reliably and free the human's mind for what no model can do safely — asking the right question.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need Claude Code, or does this work with any AI tool?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The three levers — enforced conventions, encapsulated tasks, multi-step routines — are tool-agnostic as a concept. The concrete implementation differs: Claude Code loads &lt;code&gt;.claude/rules/&lt;/code&gt; files automatically as a project instruction, other tools have their own mechanisms (project settings, system prompts, custom instructions). The principle "conventions as a machine-loaded rule" transfers; the file paths don't, 1:1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is generated SQL safe enough for production?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only with review. An AI produces plausible code — including where it's wrong. Enforced conventions lower the risk because generated code stays readable and therefore reviewable; they don't replace the review. Security-relevant logic (permission checks, mutations) deserves especially careful reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I keep the AI from ignoring my conventions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By keeping the conventions in a rules file in the repo instead of in your head. Three things help: explicit code examples instead of prose descriptions, explicit &lt;code&gt;Don'ts&lt;/code&gt; (anti-patterns), and a reason per rule, so the agent transfers it to cases it hasn't been shown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this worth it for solo developers or only in a team?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both — for different reasons. In a team, conventions prevent drift across several hands. Solo, the gain is the "second developer" that never forgets the rules: even working alone, you benefit from generated code staying consistent and still being readable in 18 months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related articles
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AI workflow&lt;/strong&gt; (the "how" behind the rules):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/deriving-sql-conventions-with-claude-code/" rel="noopener noreferrer"&gt;Deriving SQL Conventions with Claude Code — the Generate-Refine-Derive Loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/ai-code-drift-799-font-sizes/" rel="noopener noreferrer"&gt;AI-Assisted Coding Gave Me 799 Hardcoded Font Sizes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SQL conventions &amp;amp; structure&lt;/strong&gt; (the "what" the AI enforces):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/plpgsql-procedure-conventions/" rel="noopener noreferrer"&gt;SQL Conventions // PL/pgSQL Procedures You Can Still Read in Two Years&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/postgres-table-conventions/" rel="noopener noreferrer"&gt;Postgres Table Conventions — Naming, Keys and Audit Columns for a Consistent Schema&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/plpgsql-function-conventions/" rel="noopener noreferrer"&gt;PL/pgSQL Function Conventions — Volatility, RETURNS and the Boundary to Procedures&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/pl-pgsql-table-returning-functions/" rel="noopener noreferrer"&gt;PL/pgSQL Table-Returning Functions — RETURNS TABLE, SETOF and When a View Is Better&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/formatting-sql-statements-part-1/" rel="noopener noreferrer"&gt;Formatting SQL Statements (Part 1) — Identifiers, Delimiters, Commas, Aliases&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/formatting-sql-statements-part-2/" rel="noopener noreferrer"&gt;Formatting SQL Statements (Part 2) — Statement Structure: SELECT, WHERE, FROM, JOIN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/structuring-and-formatting-sql-statements/" rel="noopener noreferrer"&gt;Structuring and Formatting SQL Statements&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;ETL practice&lt;/strong&gt; (the ground for skills and agents):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/design-pattern-the-architecture-of-an-etl-process-how-to-isolate-bad-data-cleanly/" rel="noopener noreferrer"&gt;Design Pattern // The Architecture of an ETL Process&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/data-quality-in-an-etl-process/" rel="noopener noreferrer"&gt;Data Quality in an ETL Process&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sql.marcus-belz.de/en/logging-an-etl-process-with-t-sql/" rel="noopener noreferrer"&gt;Design Pattern // Logging an ETL Process with T-SQL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Starter kit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/marcusbelz/di2-starter-kit" rel="noopener noreferrer"&gt;The open DI² starter kit on GitHub&lt;/a&gt; — the rules, skills and SQL rule tree from this article as a forkable template.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>sql</category>
      <category>database</category>
    </item>
    <item>
      <title>Data Quality: Dimensions and Error Classes — the Theory Behind the SQL Checks</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Sat, 04 Jul 2026 15:38:03 +0000</pubDate>
      <link>https://dev.to/marcus1968/data-quality-dimensions-and-error-classes-the-theory-behind-the-sql-checks-47bb</link>
      <guid>https://dev.to/marcus1968/data-quality-dimensions-and-error-classes-the-theory-behind-the-sql-checks-47bb</guid>
      <description>&lt;p&gt;A lot gets written about data quality, and very little gets measured. The German-language practitioner's standard reference alone lists sixty possible quality criteria — from timeliness to reliability —, and even the lean models still arrive at six to fifteen dimensions. Yet the core of the matter is surprisingly tangible: a data error caught during loading costs you a log entry. The same error running through to the report costs a wrong invoice, a bad decision — and the trust in every number that follows. This article makes the unde&lt;/p&gt;

&lt;p&gt;rlying framework tangible, without a theory slog: it sorts the common data errors into classes, maps them to the established dimensions of data quality — and uses an honest coverage map to show which of them three generic SQL routines actually cover. And it names the two they cannot reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The essentials up front:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data errors sort into a handful of &lt;strong&gt;classes&lt;/strong&gt; — technical vs. business, and field-level vs. record-level vs. relationship-level. That is enough to place any concrete error.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;dimensions of data quality&lt;/strong&gt; are established canon, not taste — defined criteria in the German-language standard reference by Apel et al., internationally in Wang &amp;amp; Strong, DAMA, and ISO/IEC 25012.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generic SQL checks four criteria directly&lt;/strong&gt; in the database: completeness, validity, uniqueness, and referential integrity/consistency. &lt;strong&gt;Two stay out of reach&lt;/strong&gt; — content accuracy and timeliness; both need information from &lt;em&gt;outside&lt;/em&gt; the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking early is cheap, repairing late is expensive:&lt;/strong&gt; prevention at the source cuts total costs by roughly two thirds (Redman, as cited by Apel et al.) — and 100 % data quality is not the economic target anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt; none. This article provides the framing before the SQL practice begins; the theory part needs no SQL knowledge. If you want the how right away, head for the framework article &lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Data quality checks with SQL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What data errors look like: a classification&lt;/li&gt;
&lt;li&gt;The dimensions of data quality&lt;/li&gt;
&lt;li&gt;What can SQL actually check?&lt;/li&gt;
&lt;li&gt;The two blind spots&lt;/li&gt;
&lt;li&gt;What bad data costs&lt;/li&gt;
&lt;li&gt;From theory to practice&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Related Articles&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What data errors look like: a classification
&lt;/h2&gt;

&lt;p&gt;Before slicing data quality into dimensions, it helps to look at the errors themselves. They can be sorted along two axes, and together the two axes cover practically every case.&lt;/p&gt;

&lt;p&gt;The first axis is &lt;strong&gt;technical versus business&lt;/strong&gt;. A technical (syntactic) error violates the form: letters in a numeric column, a date that is no date, a foreign key pointing nowhere. You can spot these errors without any domain knowledge — they break the rules of the data type or the model. A business (semantic) error, by contrast, is formally flawless and still wrong: an age of 200, a ship date before the order date, a customer flagged as "premium" without a single order. Only domain knowledge makes the error visible.&lt;/p&gt;

&lt;p&gt;The second axis is the &lt;strong&gt;scope&lt;/strong&gt; of the error:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;field-level&lt;/strong&gt; — the error sits in a single value (&lt;code&gt;age = 200&lt;/code&gt;, &lt;code&gt;"abc"&lt;/code&gt; in a numeric column).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;record-level&lt;/strong&gt; — the error only emerges from the interplay of several fields in one row (&lt;code&gt;ship_date &amp;lt; order_date&lt;/code&gt;) or between rows of one table (two records sharing the same key).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;relationship-level&lt;/strong&gt; — the error lies in the relationship between tables (a reference whose target does not exist).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two axes combine into a compact taxonomy in which every concrete error gets a place and a responsible check routine:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frmip929qpdek14o0e1p6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frmip929qpdek14o0e1p6.png" alt="Error-class taxonomy: a matrix of technical/syntactic versus business/semantic on one axis and field-level, record-level, relationship-level on the other. Each cell names a concrete example and the violated criterion; a band below marks the missing value as the special case completeness, cutting across all cells." width="800" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;technical / syntactic&lt;/th&gt;
&lt;th&gt;business / semantic&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;field-level&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"abc"&lt;/code&gt; in a numeric column, an invalid date → &lt;em&gt;validity&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;age = 200&lt;/code&gt;, a negative price → &lt;em&gt;validity (business rule)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;record-level&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;two records with the same primary key → &lt;em&gt;uniqueness&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ship_date &amp;lt; order_date&lt;/code&gt; → &lt;em&gt;consistency (cross-field)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;relationship-level&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;foreign key pointing to a missing master → &lt;em&gt;consistency (integrity)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;"premium" customer without an order → &lt;em&gt;business rule&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One special case falls outside the grid: the value that is &lt;strong&gt;missing&lt;/strong&gt;. An empty mandatory field is neither technically nor semantically malformed — it simply is not there. A criterion of its own is responsible for it, completeness — it cannot be pinned to any single cell but cuts across the whole matrix. Two more kinds of error are absent from the matrix entirely — the value that is well-formed and still &lt;em&gt;factually&lt;/em&gt; wrong, and the value that is &lt;em&gt;outdated&lt;/em&gt;. We will come back to both; they are the blind spots.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dimensions of data quality
&lt;/h2&gt;

&lt;p&gt;The classification says &lt;em&gt;what&lt;/em&gt; an error looks like. The &lt;strong&gt;dimensions of data quality&lt;/strong&gt; say &lt;em&gt;which property&lt;/em&gt; of good data it violates. There is no shortage of theory here — rather a surplus, and that is why the term so often stays fuzzy.&lt;/p&gt;

&lt;p&gt;The German-language practitioner's standard reference on data quality in business intelligence projects is &lt;em&gt;Datenqualität erfolgreich steuern&lt;/em&gt; ("Managing Data Quality Successfully") by Apel, Behme, Eberlein, and Merighi (3rd edition, Edition TDWI). Following Würthele, it defines data quality as a "multidimensional measure of the suitability of data to fulfill the purpose tied to its capture/generation" — a suitability that can change over time as needs change (all book quotes translated from the German original). Two things are already baked into this definition: quality is &lt;strong&gt;multidimensional&lt;/strong&gt;, and it is &lt;strong&gt;purpose-bound&lt;/strong&gt;. There is no absolute "good", only a "good enough for this purpose".&lt;/p&gt;

&lt;p&gt;How many dimensions there are is a matter of definition. The book starts with an alphabetical catalog of sixty possible quality criteria and narrows it down to the practically viable ones; for the business intelligence context it highlights six — accuracy, consistency, reliability, completeness, timeliness, and relevance. For this article, the criteria that matter are the ones data errors can technically be pinned to:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;What it demands (per Apel et al.)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Completeness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The attributes are populated with values that "semantically differ from the value &lt;code&gt;NULL&lt;/code&gt; (unknown)"; no data gets lost in transformations.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Validity&lt;/strong&gt; &lt;em&gt;(in the book: formal accuracy + uniformity)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;The values arrive in the predefined format and are represented uniformly — in practice, the allowed value range belongs here too.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Uniqueness&lt;/strong&gt; &lt;em&gt;(in the book: freedom from redundancy + key uniqueness)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;No record describes the same real-world entity twice; the business key occurs only as often as it may.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Referential integrity / consistency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every foreign key uniquely references an existing primary key; values do not contradict each other — within a record, between records, across applications.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Accuracy (content)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The values match the entities of the real world — the data corresponds to reality.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Timeliness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The records reflect the current state of the modeled world and are not outdated.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two names in this table each bundle two of the book's criteria — the book slices finer than international usage does. &lt;strong&gt;Validity&lt;/strong&gt; does appear in the catalog, but the actual definition lives in the &lt;em&gt;formal&lt;/em&gt; component of accuracy (delivery in the predefined data format) and in uniformity. And what DAMA and ISO call &lt;strong&gt;uniqueness&lt;/strong&gt;, the book lists as freedom from redundancy and key uniqueness; its own criterion named "uniqueness" means something else there, namely the unambiguous interpretability of a record through its metadata. Key uniqueness the book phrases in terms of primary keys — yet the check is only meaningful on the &lt;em&gt;business&lt;/em&gt; key, because a constraint-enforced primary key cannot occur twice in the first place. The check gets interesting exactly where the constraint is (still) missing: in staging tables and at interfaces. This article sticks to the common names and means the book criteria listed above.&lt;/p&gt;

&lt;p&gt;That several such lists exist is not a contradiction but a method: group the criteria and you get a &lt;em&gt;quality model&lt;/em&gt;. The book presents two side by side — a theoretical taxonomy after Hinrichs, and the categorization of the German Society for Information and Data Quality (DGIQ), derived from a user survey and in turn based on the much-cited study by &lt;strong&gt;Wang and Strong (1996)&lt;/strong&gt; with its fifteen dimensions. Internationally, the &lt;strong&gt;six dimensions of DAMA UK&lt;/strong&gt; (2013) and the &lt;strong&gt;ISO/IEC 25012&lt;/strong&gt; standard are widely used as well. They are different cuts through the same subject — which one fits depends on the purpose. For the rest of this article we stay with the book's criteria, because they point most directly at an SQL check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can SQL actually check?
&lt;/h2&gt;

&lt;p&gt;Now for the decisive question: how many of these criteria does a handful of generic SQL checks actually reach? The practical framework of the &lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;SQL check series&lt;/a&gt; builds on three routines — a value-based &lt;code&gt;WHERE&lt;/code&gt; check, a duplicate check, and a foreign-key check. Plot them against the criteria and you get this coverage map:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjj4p3u8l0wc0qrczmtb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjj4p3u8l0wc0qrczmtb.png" alt="Coverage map: three SQL routines (value check, duplicate check, foreign-key check) against the data quality criteria per Apel et al. Four criteria — completeness, validity, uniqueness, and referential integrity/consistency — are marked as covered, two — accuracy and timeliness — as gaps." width="799" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;verifiable with SQL?&lt;/th&gt;
&lt;th&gt;Routine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Completeness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ yes&lt;/td&gt;
&lt;td&gt;value check (&lt;code&gt;IS NULL&lt;/code&gt; on mandatory fields)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Validity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ yes&lt;/td&gt;
&lt;td&gt;value check (value range, format, safe type conversion)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Uniqueness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ yes&lt;/td&gt;
&lt;td&gt;duplicate check (&lt;code&gt;GROUP BY … HAVING count(*) &amp;gt; 1&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Referential integrity / consistency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ partly&lt;/td&gt;
&lt;td&gt;foreign-key check; cross-field rules in the &lt;code&gt;WHERE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Accuracy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Timeliness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four criteria, then, are within reach of generic, configurable SQL — and with surprisingly little code. "Verifiable" means: SQL &lt;em&gt;executes&lt;/em&gt; the rule; the domain has to formulate it — a &lt;code&gt;price &amp;gt; 0&lt;/code&gt; is written into no engine by default. &lt;strong&gt;Completeness&lt;/strong&gt; is an &lt;code&gt;IS NULL&lt;/code&gt; check on the mandatory columns — in the book's wording, "the individual attributes contain no &lt;code&gt;NULL&lt;/code&gt; values". &lt;strong&gt;Validity&lt;/strong&gt; is a value-range or format check; its classic case is safe type conversion, which — for example with &lt;a href="https://sql.marcus-belz.de/en/dataquality-converting-data-to-date-datetime-datetime2-time/" rel="noopener noreferrer"&gt;TRY_CONVERT&lt;/a&gt; in SQL Server — turns an invalid value into &lt;code&gt;NULL&lt;/code&gt; instead of aborting the load (more in the &lt;a href="https://sql.marcus-belz.de/en/type-conversion-basics-t-sql/" rel="noopener noreferrer"&gt;type conversion basics&lt;/a&gt;). &lt;strong&gt;Uniqueness&lt;/strong&gt; is a grouping over the business key with &lt;code&gt;HAVING count(*) &amp;gt; 1&lt;/code&gt;. &lt;strong&gt;Referential integrity&lt;/strong&gt; — in the book's wording, "every foreign key must uniquely reference an existing primary key" — is a foreign-key check.&lt;/p&gt;

&lt;p&gt;The "partly" for consistency deserves honesty — and a distinction: referential integrity is only the relational special case of consistency; the book explicitly calls its key criteria a "special alignment toward the relational database model". Consistency itself reaches further: the book explicitly includes the &lt;em&gt;reconciliation of data across different applications&lt;/em&gt; — that a value does not contradict itself across systems. A single SQL check in &lt;em&gt;one&lt;/em&gt; database cannot deliver that cross-system reconciliation; the variants checkable &lt;em&gt;inside&lt;/em&gt; the database (referential integrity, cross-field rules within a row) it covers cleanly. The boundary, however, is set by the process, not the technology: bring the data needed for the reconciliation — the master from the other system, the reference table of the other application — into the ETL process, and they sit side by side in &lt;em&gt;one&lt;/em&gt; database; the cross-system reconciliation becomes an ordinary SQL check. The foreign-key check then runs against a master that originally came from an entirely different system.&lt;/p&gt;

&lt;p&gt;The book draws this line itself, by the way — just elsewhere: in its example metrics per criterion (chapter 7, table 7–3), completeness is an automated "number of &lt;code&gt;NULL&lt;/code&gt; values" query, while content accuracy and timeliness list "user feedback" as the measuring method. Where no query can reach, a human has to answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two blind spots
&lt;/h2&gt;

&lt;p&gt;That leaves the two criteria where SQL alone has to pass — not because of a weakness of the language, but because the required information simply is not in the database. Both sit in the book's catalog right next to the checkable ones.&lt;/p&gt;

&lt;p&gt;The first blind spot is &lt;strong&gt;timeliness&lt;/strong&gt;. Whether an address is "current" cannot be read off the address itself — it looks identical yesterday and today. Timeliness demands that the data reflect "the current state of the modeled world", as the book puts it — and that takes a &lt;em&gt;temporal reference&lt;/em&gt;: a timestamp of when the value was last confirmed, and an expectation of how long it stays valid. If such a &lt;code&gt;last_verified&lt;/code&gt; field exists, SQL can check the rule (&lt;code&gt;last_verified &amp;lt; now() - interval '1 year'&lt;/code&gt;) — but then it checks the completeness and validity &lt;em&gt;of that timestamp&lt;/em&gt;, not the currency of the actual value. Without the timestamp, the criterion is invisible to a pure database check.&lt;/p&gt;

&lt;p&gt;The second blind spot is &lt;strong&gt;accuracy&lt;/strong&gt; — and it is the subtlest point of this whole article, because the book defines it as having "a content component and a formal component". The &lt;em&gt;formal&lt;/em&gt; side — the right data type, the predefined format — is exactly validity, and thus checkable. The &lt;em&gt;content&lt;/em&gt; side is not: a birth date of &lt;code&gt;1990-05-14&lt;/code&gt; is perfectly valid — right type, plausible range, clean format. Whether it is the person's &lt;em&gt;actual&lt;/em&gt; birth date, the database does not know and cannot know. That would take an external source of truth: the ID document, a population register, a second independent record. SQL compares data against &lt;em&gt;rules&lt;/em&gt;, not against the &lt;em&gt;world&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This makes the boundary more precise: accuracy is unverifiable &lt;em&gt;as long as the source of truth lies outside the database&lt;/em&gt;. As soon as it becomes available as data — say, a leading system that is co-extracted in the ETL process, the same pattern as with the cross-system consistency reconciliation —, the check becomes an ordinary SQL comparison: source against leading system, deviation equals finding. The typical case is data migration, where the target system already knows many of the records; how to build such reconciliations systematically is shown in the article on &lt;a href="https://sql.marcus-belz.de/en/verify-migration-data-quality/" rel="noopener noreferrer"&gt;verifying a migration&lt;/a&gt;. What is checked then, however, is agreement with the &lt;em&gt;designated&lt;/em&gt; source of truth — whether that source itself agrees with the world remains a governance decision, not an SQL question.&lt;/p&gt;

&lt;p&gt;This boundary is not an excuse but the honest core of the matter: an SQL check guarantees that data is &lt;em&gt;well-formed and internally coherent&lt;/em&gt; — not that it is &lt;em&gt;true&lt;/em&gt;. Whoever says "automatically checked" has to name the two criteria left out — otherwise they are selling a green checkmark as proof of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bad data costs
&lt;/h2&gt;

&lt;p&gt;You will find plenty of charts online along the lines of "37 % of all data errors are completeness errors". No such chart appears here, deliberately — there is no citable primary source for a reliable frequency distribution per dimension, and a made-up number would be exactly the kind of data error this article is about.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;is&lt;/em&gt; citable is the economics behind it — and it has two sides. The first concerns &lt;em&gt;when&lt;/em&gt; you check. The &lt;strong&gt;1-10-100 rule&lt;/strong&gt; originally comes from quality management (Labovitz and Chang, &lt;em&gt;Making Quality Work&lt;/em&gt;, 1992) and has since been applied to data quality: what costs &lt;strong&gt;1&lt;/strong&gt; to prevent at the source costs &lt;strong&gt;10&lt;/strong&gt; to correct afterwards and &lt;strong&gt;100&lt;/strong&gt; if you let the error take effect — as a wrong invoice, a lost customer, a bad decision. The specific factors are a rule of thumb, not a law of nature; the direction, though, is undisputed and matches everyday experience: an error gets more expensive the later it surfaces. That is exactly why checking &lt;em&gt;at the source&lt;/em&gt; (staging) pays off, before the data flows on — there an error is still reportable, at the target it is fatal.&lt;/p&gt;

&lt;p&gt;The second side concerns not &lt;em&gt;when&lt;/em&gt; you check but &lt;em&gt;how far&lt;/em&gt; you should push data quality. In their book &lt;em&gt;Datenqualität erfolgreich steuern&lt;/em&gt; (3rd edition, Edition TDWI, figure 3–2, p. 43), Apel, Behme, Eberlein, and Merighi contrast two opposing cost curves: the &lt;strong&gt;costs caused by poor data quality&lt;/strong&gt; fall as quality rises — bad decisions, rework, and lost customers become rarer. The &lt;strong&gt;costs of producing and assuring good quality&lt;/strong&gt; rise instead, and disproportionately so the closer you get to 100 %. The sum of both — the &lt;em&gt;total quality cost&lt;/em&gt; — has its minimum not at 100 % but at an optimum in between. That is the core message: 100 % data quality is rarely the economic target; what you are looking for is the most cost-effective combination for the purpose at hand.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6z7tpr4y10bfcu8omodc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6z7tpr4y10bfcu8omodc.png" alt="Two opposing cost curves over data quality (x-axis up to 100 percent, y-axis costs): the costs caused by poor data quality fall as quality rises, the costs of improving and assuring it rise and shoot up toward 100 percent. A dashed sum curve hints at the total costs; a gray band at the intersection marks their minimum — the economic optimum, well before 100 percent." width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Own rendering after Apel et al., "Datenqualität erfolgreich steuern" (3rd edition, Edition TDWI, figure 3–2, p. 43) — the classic total-cost-of-quality model.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The chart is deliberately simplified — a principle, not a measurement; the optimum shifts with data and purpose. Two practical consequences follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Achieve a lot with little effort.&lt;/strong&gt; The production curve stays flat for a long stretch and only explodes at the top end. The cheap early stretch is the obvious &lt;code&gt;NULL&lt;/code&gt; values, broken formats, and duplicates that generic SQL extracts with little effort — exactly the four checkable criteria. The last few percent, and even more so the two uncheckable criteria (accuracy and timeliness), cost far more and are only worth it where the domain demands it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevent early instead of cleaning up late.&lt;/strong&gt; Prevention at the source is the strongest lever: avoiding errors there, instead of detecting and cleansing them later, cuts total costs by roughly two thirds on average, according to the research cited in the same chapter (Redman 2008).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  From theory to practice
&lt;/h2&gt;

&lt;p&gt;The framework is in place: errors sort into a handful of classes, good data describes itself in a handful of criteria, and four of them are checkable with generic SQL. The road from here into practice runs through four articles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;framework&lt;/strong&gt; — &lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Data quality checks with SQL&lt;/a&gt; — builds the shared error table, the severity gate, and the configurable runner that generates the three routines at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validity + completeness&lt;/strong&gt; are deepened in &lt;a href="https://sql.marcus-belz.de/en/validate-data-with-sql/" rel="noopener noreferrer"&gt;Validate data with SQL&lt;/a&gt; — value ranges, mandatory fields, and the distinction between "no value" and "unknown value".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uniqueness&lt;/strong&gt; is deepened in &lt;a href="https://sql.marcus-belz.de/en/find-duplicates-with-sql/" rel="noopener noreferrer"&gt;Find duplicates with SQL&lt;/a&gt; — from &lt;code&gt;count(*) &amp;gt; 1&lt;/code&gt; to composite keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency / integrity&lt;/strong&gt; is deepened in &lt;a href="https://sql.marcus-belz.de/en/find-orphaned-records-sql/" rel="noopener noreferrer"&gt;Find orphaned records&lt;/a&gt; — checking referential integrity, even without a foreign-key constraint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the dimensions in mind, these four articles no longer read as loose tricks but as what they are: one answer each to one measurable property of good data.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What data quality dimensions are there?&lt;/strong&gt;&lt;br&gt;
It depends on the model. The German-language practitioner's standard reference (Apel et al., &lt;em&gt;Datenqualität erfolgreich steuern&lt;/em&gt;) lists sixty possible quality criteria and highlights six for the business intelligence context: accuracy, consistency, reliability, completeness, timeliness, and relevance. Internationally, the six dimensions of DAMA UK (2013) and the fifteen characteristics of ISO/IEC 25012 are widespread; the academic origin is the fifteen dimensions of Wang &amp;amp; Strong (1996). They all describe the same subject at different resolutions — which list fits depends on the purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between validity and accuracy?&lt;/strong&gt;&lt;br&gt;
Validity means: the value conforms to the rules — right type, valid format, allowed value range. Accuracy means: the value corresponds to reality. The standard reference bundles both under "accuracy" (a content and a formal component) — but for an SQL check, exactly this separation is decisive: a birth date can be perfectly valid (well-formed, plausible) and still factually wrong. SQL checks the formal side, because the rules live in the database; content agreement with reality needs an external source of truth and cannot be checked with SQL alone. There is one exception: if a designated source of truth is available as data — such as the leading system in a data migration —, the accuracy check becomes an SQL reconciliation against that source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many data quality dimensions can you check with SQL?&lt;/strong&gt;&lt;br&gt;
Four criteria can be checked with generic SQL directly in the database: completeness (&lt;code&gt;IS NULL&lt;/code&gt; check on mandatory fields), validity (value range/format), uniqueness (&lt;code&gt;GROUP BY … HAVING count(*) &amp;gt; 1&lt;/code&gt;), and referential integrity/consistency (foreign-key check). Not checkable are content accuracy and timeliness — both need information from outside the database (an external reference or a timestamp).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between technical and business data errors?&lt;/strong&gt;&lt;br&gt;
A technical (syntactic) error violates the form and is detectable without domain knowledge: letters in a numeric column, a foreign key pointing nowhere. A business (semantic) error is formally correct and still wrong: an age of 200 or a ship date before the order date. Generic SQL catches technical errors easily; business errors need an explicitly formulated business rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a tool to measure data quality?&lt;/strong&gt;&lt;br&gt;
Not necessarily for the four checkable criteria — a handful of generic SQL checks with a central error table covers completeness, validity, uniqueness, and referential integrity. Specialized tools (dbt tests, Great Expectations, Soda) automate and orchestrate this more comfortably and add reporting, but at their core they check the same criteria. Accuracy and timeliness they do not solve either — not without an external reference or a timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Data quality checks with SQL&lt;/a&gt; — the configurable SQL framework that casts the four checkable criteria of this article into three generic routines: shared error table, severity gate, dynamic runner.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/validate-data-with-sql/" rel="noopener noreferrer"&gt;Validate data with SQL&lt;/a&gt; — the routine for validity and completeness, including the NULL trap.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/find-duplicates-with-sql/" rel="noopener noreferrer"&gt;Find duplicates with SQL&lt;/a&gt; — the routine for uniqueness: maximum cardinality and composite keys.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/find-orphaned-records-sql/" rel="noopener noreferrer"&gt;Find orphaned records&lt;/a&gt; — the routine for consistency / referential integrity.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-in-an-etl-process/" rel="noopener noreferrer"&gt;Data quality in an ETL process&lt;/a&gt; — the bigger picture: where in the ETL process the dimensions are checked and how bad data is isolated.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dataquality</category>
      <category>sql</category>
      <category>database</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Data Migration: SQL Server to PostgreSQL — the Complete Guide</title>
      <dc:creator>Marcus</dc:creator>
      <pubDate>Tue, 30 Jun 2026 12:46:36 +0000</pubDate>
      <link>https://dev.to/marcus1968/data-migration-sql-server-to-postgresql-the-complete-guide-4mgk</link>
      <guid>https://dev.to/marcus1968/data-migration-sql-server-to-postgresql-the-complete-guide-4mgk</guid>
      <description>&lt;p&gt;A &lt;strong&gt;data migration&lt;/strong&gt; from SQL Server to PostgreSQL rarely fails at actually copying the data. It fails at the silent differences that only surface in the target: &lt;code&gt;datetime&lt;/code&gt;, which knows no time zone, &lt;code&gt;bit&lt;/code&gt;, which is not a &lt;code&gt;boolean&lt;/code&gt;, an &lt;code&gt;IDENTITY&lt;/code&gt; that turns into a sequence, and a collation that suddenly compares case-sensitively. Anyone who sets out to &lt;strong&gt;migrate SQL Server to PostgreSQL&lt;/strong&gt; isn't just copying tables — they're translating types, schema, code and behaviour from one engine into another.&lt;/p&gt;

&lt;p&gt;This guide gives the overview: it sorts the move into five phases and names the key trip-ups for each. The depth lives in a dedicated article per phase — this one supplies the through-line that ties the phases together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The essentials up front:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Five phases instead of "moving data":&lt;/strong&gt; the move breaks down into data types, schema/DDL, data transfer, code porting and verification. Tackling them in this order spares you the typical setbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each phase with its own trip-up:&lt;/strong&gt; sometimes it's breaking types, sometimes &lt;code&gt;IDENTITY&lt;/code&gt; becomes a sequence, sometimes it's the transfer method or translating T-SQL into PL/pgSQL. None of them shows up until it strikes in the target — which is why verification comes last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What tooling takes off your plate:&lt;/strong&gt; tools like &lt;code&gt;pgloader&lt;/code&gt; handle the mechanical ~80% — the uncritical types and the bulk transfer. The last 20% — type edge cases, procedure logic, triggers — stay handwork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The through-line:&lt;/strong&gt; each phase has its own detailed article with the full depth. This hub connects them and tells you in which order they play together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prerequisite:&lt;/strong&gt; a basic grasp of relational databases. SQL Server 2017+ as the source, PostgreSQL 16/17 as the target. Postgres concepts (sequences, &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, PL/pgSQL) are placed in context briefly on first mention — no prior Postgres knowledge required, T-SQL basics yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Why migrate SQL Server to PostgreSQL?&lt;/li&gt;
&lt;li&gt;The migration path in five phases&lt;/li&gt;
&lt;li&gt;One Table, Several Phases at Once&lt;/li&gt;
&lt;li&gt;What tooling takes off your plate — and where handwork remains&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Related articles&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why migrate SQL Server to PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;The reasons are rarely technical — they're usually economic or strategic. &lt;strong&gt;Licensing costs&lt;/strong&gt; fall away: PostgreSQL is open source and usable without core or CAL licences. The &lt;strong&gt;open-source stack&lt;/strong&gt; runs freely on any infrastructure, with no vendor lock-in. And &lt;strong&gt;cloud portability&lt;/strong&gt; is high — nearly every provider offers managed PostgreSQL.&lt;/p&gt;

&lt;p&gt;This is no holy war over "Postgres is better than SQL Server". Both engines are mature, and not every workload belongs on the move. The point is sober: once the decision to &lt;strong&gt;migrate SQL Server to PostgreSQL&lt;/strong&gt; has been made, it pays to plan the move as a structured path rather than a one-off copy operation. That's exactly what this guide is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration path in five phases
&lt;/h2&gt;

&lt;p&gt;A database migration is not a single step but a chain of dependent phases. Tackling them in this order avoids the typical setbacks — such as transferring data before the target schema has the right types. Each phase has its own detailed article.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Data types.&lt;/strong&gt; The foundation. Most types convert one-to-one (&lt;code&gt;int&lt;/code&gt;, &lt;code&gt;numeric&lt;/code&gt;, &lt;code&gt;varchar&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;) — but &lt;code&gt;datetime&lt;/code&gt;, &lt;code&gt;bit&lt;/code&gt;, &lt;code&gt;money&lt;/code&gt;, &lt;code&gt;uniqueidentifier&lt;/code&gt;, &lt;code&gt;nvarchar&lt;/code&gt; and &lt;code&gt;tinyint&lt;/code&gt; have &lt;strong&gt;no&lt;/strong&gt; one-to-one equivalent. &lt;code&gt;datetime&lt;/code&gt; forces the time-zone question (&lt;code&gt;timestamp&lt;/code&gt; vs. &lt;code&gt;timestamptz&lt;/code&gt;), &lt;code&gt;bit&lt;/code&gt; becomes a real &lt;code&gt;boolean&lt;/code&gt;, and the Postgres &lt;code&gt;money&lt;/code&gt; type is best left untouched. Which types convert cleanly and which break is covered in &lt;a href="https://sql.marcus-belz.de/en/data-type-mapping-sql-server-postgresql/" rel="noopener noreferrer"&gt;Data Type Mapping SQL Server → PostgreSQL&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Schema &amp;amp; DDL.&lt;/strong&gt; Once you have the types comes the structure around them: &lt;code&gt;IDENTITY&lt;/code&gt; becomes &lt;code&gt;GENERATED AS IDENTITY&lt;/code&gt; or a sequence, default constraints and named constraints move over, and the case sensitivity of identifiers flips from SQL-Server-tolerant to Postgres-exact. Details in &lt;a href="https://sql.marcus-belz.de/en/schema-migration-sql-server-to-postgresql/" rel="noopener noreferrer"&gt;Schema Migration SQL Server → PostgreSQL&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Data transfer.&lt;/strong&gt; Only once the target schema stands do the data move. The choice of method depends on data volume and downtime tolerance: a &lt;code&gt;bcp&lt;/code&gt; export plus &lt;code&gt;COPY&lt;/code&gt;, the all-in-one tool &lt;code&gt;pgloader&lt;/code&gt;, or an ETL pipeline. Which method fits when is compared in &lt;a href="https://sql.marcus-belz.de/en/transfer-data-sql-server-to-postgresql/" rel="noopener noreferrer"&gt;Transferring Data: bcp, COPY, pgloader, ETL&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Code porting.&lt;/strong&gt; Tables are only half the database. Stored procedures, functions and triggers have to be translated from T-SQL to PL/pgSQL — different error handling, different variable syntax, different idioms (&lt;code&gt;TRY_CONVERT&lt;/code&gt; has no direct counterpart). This is the part with the highest handwork share. Explored in depth in &lt;a href="https://sql.marcus-belz.de/en/port-t-sql-to-pl-pgsql/" rel="noopener noreferrer"&gt;Porting T-SQL to PL/pgSQL&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Verification.&lt;/strong&gt; A migration is only done once you've proven that nothing was lost or silently corrupted: row reconciliation per table, spot-check comparisons, data-quality checks after the load. How to check that systematically is shown in &lt;a href="https://sql.marcus-belz.de/en/verify-migration-data-quality/" rel="noopener noreferrer"&gt;Verifying the Migration — Data Quality and Row Reconciliation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The phases build on one another but can be iterated — it's typical to run types and schema together on a pilot table before transferring the full load.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Table, Several Phases at Once
&lt;/h2&gt;

&lt;p&gt;To make the phase path tangible, a small table that bundles several trip-ups at once. First the source in T-SQL:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;customer_id&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt;             &lt;span class="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;full_name&lt;/span&gt;     &lt;span class="n"&gt;nvarchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;     &lt;span class="nb"&gt;bit&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="mi"&gt;1&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="nb"&gt;datetime&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;GETDATE&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="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;pk_customer&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;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="mi"&gt;8&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 the same as a PostgreSQL target:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;customer_id&lt;/span&gt;   &lt;span class="nb"&gt;integer&lt;/span&gt;       &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;
  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;full_name&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;          &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;     &lt;span class="nb"&gt;boolean&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="k"&gt;true&lt;/span&gt;
  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="nb"&gt;timestamp&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="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;pk_customer&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;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="mi"&gt;8&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;Four columns, and already two phases interlock — data types and schema — in a table nobody would call migration-critical. Three of the changes you see coming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line 3 (schema):&lt;/strong&gt; &lt;code&gt;int IDENTITY(1, 1)&lt;/code&gt; → &lt;code&gt;integer GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt; — the auto-value column moves to the SQL-standard mechanism. The real work comes after the load: the sequence must be advanced to the highest value, or the next &lt;code&gt;INSERT&lt;/code&gt; collides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line 5 (data type):&lt;/strong&gt; &lt;code&gt;bit&lt;/code&gt; → &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt; becomes &lt;code&gt;true&lt;/code&gt;. Beware: ported queries like &lt;code&gt;WHERE is_active = 1&lt;/code&gt; break in Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line 6 (data type):&lt;/strong&gt; &lt;code&gt;datetime&lt;/code&gt; → &lt;code&gt;timestamp&lt;/code&gt;. The trap isn't &lt;code&gt;now()&lt;/code&gt; instead of &lt;code&gt;GETDATE()&lt;/code&gt;, but that &lt;code&gt;datetime&lt;/code&gt; carries no time zone: whether &lt;code&gt;timestamp&lt;/code&gt; or &lt;code&gt;timestamptz&lt;/code&gt; is right depends on whether the values were meant as local time or UTC — if you don't decide deliberately, it shifts silently later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fourth one slips by when you skim — and is the most likely to go unnoticed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line 4 (data type):&lt;/strong&gt; &lt;code&gt;nvarchar(100)&lt;/code&gt; → &lt;code&gt;text&lt;/code&gt;. "Postgres is natively Unicode, so &lt;code&gt;text&lt;/code&gt;" is true — but it hides that the &lt;strong&gt;length limit disappears&lt;/strong&gt;. This is exactly what an auto-converter like &lt;code&gt;pgloader&lt;/code&gt; reaches for by default: &lt;code&gt;nvarchar(100)&lt;/code&gt; becomes &lt;code&gt;text&lt;/code&gt;, the limit drops without being asked. If the &lt;code&gt;100&lt;/code&gt; was just technical baggage, &lt;code&gt;text&lt;/code&gt; is the right choice. If it was a business rule — the database was not allowed to accept anything longer — a silent validation has been lost, and what belongs there is a &lt;code&gt;varchar(100)&lt;/code&gt; or a &lt;code&gt;CHECK&lt;/code&gt; constraint rather than bare &lt;code&gt;text&lt;/code&gt;. The tool makes the translation automatically — but whether the limit was intended by the domain, it cannot know: that question isn't in the type, it's in the domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole guide in one table: four lines that look like search-and-replace carry two phases and at least one trap you only see if you know the data by its meaning — not just its type.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tooling takes off your plate — and where handwork remains
&lt;/h2&gt;

&lt;p&gt;The honest expectation-setting first: there is &lt;strong&gt;no&lt;/strong&gt; "one click and done". Tools like &lt;code&gt;pgloader&lt;/code&gt; (free, takes schema and data over in a single run) or commercial converters handle the mechanical bulk — and that's a lot: the uncritical types, the bulk transfer, the standard constraints. As a rule of thumb they cover around 80% of the mechanics.&lt;/p&gt;

&lt;p&gt;The remaining 20% are exactly the spots where a business decision is needed that no tool can know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Breaking types&lt;/strong&gt; — &lt;code&gt;datetime&lt;/code&gt; → &lt;code&gt;timestamp&lt;/code&gt; or &lt;code&gt;timestamptz&lt;/code&gt;? &lt;code&gt;money&lt;/code&gt; → which &lt;code&gt;numeric&lt;/code&gt; scale? &lt;code&gt;tinyint&lt;/code&gt; with a preserving &lt;code&gt;CHECK&lt;/code&gt; constraint? These cases need checking, not blind adoption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Procedure and trigger logic&lt;/strong&gt; — T-SQL to PL/pgSQL is translation work, not search-and-replace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance tuning&lt;/strong&gt; — indexes, statistics and query plans differ; what was fast in SQL Server may need a different index in Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavioural differences&lt;/strong&gt; — collation/case, NULL ordering, transaction semantics on errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The art of migration lies not in transferring the easy 80% but in the clean, verified translation of the hard 20%. This cluster devotes a dedicated article to each of these spots.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How long does a SQL-Server-to-Postgres migration take?&lt;/strong&gt;&lt;br&gt;
It depends on schema complexity and the amount of code, not primarily on data volume. The data transfer itself is often done in hours; the time goes into code porting (stored procedures, triggers) and verification. A simple database with little logic is doable in days, one with hundreds of procedures in weeks to months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I automate this completely?&lt;/strong&gt;&lt;br&gt;
No. Tools like &lt;code&gt;pgloader&lt;/code&gt; take the mechanical ~80% off your plate — the uncritical types and the bulk transfer. The last 20% (breaking types, procedure logic, triggers, performance tuning) need human decisions. Expecting "one click and done" builds in silent errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to my stored procedures?&lt;/strong&gt;&lt;br&gt;
They have to be ported from T-SQL to PL/pgSQL — that's the most demanding phase. Error handling (&lt;code&gt;TRY/CATCH&lt;/code&gt; → &lt;code&gt;EXCEPTION&lt;/code&gt;), variable syntax and many idioms differ. There's no direct translation tool; the dedicated article on code porting shows the patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need downtime?&lt;/strong&gt;&lt;br&gt;
For the simplest variant (export → transfer → switch over), yes — the source is ideally read-only during the transfer so no changes are lost. Low-downtime strategies (logical replication, gradual cutover) are possible but considerably more involved and a topic of their own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I migrate gradually or as a big bang?&lt;/strong&gt;&lt;br&gt;
For most solo and mid-size scenarios, a big-bang cutover in a quiet maintenance window is pragmatic. Gradual migration (both systems in parallel) pays off for large, continuously available systems — but at the cost of considerable synchronisation complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this also work for Azure SQL or other source databases?&lt;/strong&gt;&lt;br&gt;
The phases (types → schema → transfer → code → verification) apply generally, and Azure SQL is closely related to SQL Server — much of it carries over. This cluster, though, is specifically tailored to &lt;strong&gt;SQL Server → PostgreSQL&lt;/strong&gt;; Oracle or MySQL as the source bring different type and dialect traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related articles
&lt;/h2&gt;

&lt;p&gt;This guide is the &lt;strong&gt;overview&lt;/strong&gt; of the cluster on &lt;strong&gt;migrating from SQL Server to PostgreSQL&lt;/strong&gt;. Each phase has its own detailed article:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data types:&lt;/strong&gt; &lt;a href="https://sql.marcus-belz.de/en/data-type-mapping-sql-server-postgresql/" rel="noopener noreferrer"&gt;Data Type Mapping SQL Server → PostgreSQL — what converts cleanly and what breaks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema &amp;amp; DDL:&lt;/strong&gt; &lt;a href="https://sql.marcus-belz.de/en/schema-migration-sql-server-to-postgresql/" rel="noopener noreferrer"&gt;Schema Migration SQL Server → PostgreSQL — Identity, Constraints, Defaults, Sequences&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data transfer:&lt;/strong&gt; &lt;a href="https://sql.marcus-belz.de/en/transfer-data-sql-server-to-postgresql/" rel="noopener noreferrer"&gt;Transferring Data: bcp, COPY, pgloader, ETL — Which Method When&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code porting:&lt;/strong&gt; &lt;a href="https://sql.marcus-belz.de/en/port-t-sql-to-pl-pgsql/" rel="noopener noreferrer"&gt;Porting T-SQL to PL/pgSQL — Migrating Procedures and Functions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification:&lt;/strong&gt; &lt;a href="https://sql.marcus-belz.de/en/verify-migration-data-quality/" rel="noopener noreferrer"&gt;Verifying the Migration — Data Quality and Row Reconciliation&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For more on adjacent topics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/postgres-table-conventions/" rel="noopener noreferrer"&gt;Postgres Table Conventions&lt;/a&gt; — what the target schema looks like idiomatically&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/etl-vs-elt-explained/" rel="noopener noreferrer"&gt;ETL vs. ELT — Explained&lt;/a&gt; — transfer patterns and tool choice&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/data-quality-checks-with-sql/" rel="noopener noreferrer"&gt;Data Quality Checks with SQL&lt;/a&gt; — the checking framework for the verification phase&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sql.marcus-belz.de/en/type-conversion-basics-t-sql/" rel="noopener noreferrer"&gt;Data Quality // Fundamentals of Type Conversion with T-SQL&lt;/a&gt; — background on the conversion pitfalls&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>database</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
