<?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: Arseniy Potapov</title>
    <description>The latest articles on DEV Community by Arseniy Potapov (@potapov).</description>
    <link>https://dev.to/potapov</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3782696%2F7aefe108-ee8b-42f0-b8ee-de6f4dfe9a14.jpeg</url>
      <title>DEV Community: Arseniy Potapov</title>
      <link>https://dev.to/potapov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/potapov"/>
    <language>en</language>
    <item>
      <title>Using Claude Code Without Technical Debt</title>
      <dc:creator>Arseniy Potapov</dc:creator>
      <pubDate>Tue, 03 Mar 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/potapov/using-claude-code-without-technical-debt-1a7</link>
      <guid>https://dev.to/potapov/using-claude-code-without-technical-debt-1a7</guid>
      <description>&lt;p&gt;Last month I spent three days debugging race conditions where overlapping transactions interlocked and corrupted shared state. The code passed every test. Linters were clean. Type checks passed. In production, under real load, two users hitting the same resource at the same time broke everything.&lt;/p&gt;

&lt;p&gt;Some of that code was AI-assisted. Not all of it - race conditions don't need AI to exist - but the AI-generated parts had sailed through review because they looked impeccable. Syntactically perfect. Well-structured. Exactly the kind of code you glance at and think "looks good." That's the trap. Auto-approving AI output because it reads well is how you end up spending a week staring at transaction logs instead of shipping features.&lt;/p&gt;

&lt;p&gt;That experience crystallized something I'd been feeling for months. AI tools write code that's correct in isolation - functions that do what you asked, following patterns that pass every static check. But production isn't isolation. Production is concurrent users, stale caches, network partitions, and data that doesn't look like your test fixtures. The gap between "works in dev" and "survives production" is where technical debt hides.&lt;/p&gt;

&lt;p&gt;I use Claude Code every day for production AI/ML systems. I'm not here to tell you to stop using AI tools - I'd be a hypocrite. I'm here to share the workflows I've built after learning these lessons the hard way. Two modes of working with AI, a context-priming system that makes output dramatically more predictable, and the red flags I've trained myself to catch before they reach production.&lt;/p&gt;

&lt;p&gt;Here's how I use Claude Code without losing sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI Gets Wrong (And Why It's Hard to Spot)
&lt;/h2&gt;

&lt;p&gt;AI-generated code has a dangerous property: it looks right. Clean variable names, correct syntax, reasonable structure. It passes lint, passes type checks, often passes tests. The problems are in the things you can't see by reading the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Doesn't Understand Your Runtime
&lt;/h3&gt;

&lt;p&gt;AI generates code in a vacuum. It doesn't know that your FastAPI endpoint gets hit by 200 concurrent users during batch processing. It doesn't know that your Celery workers share a database connection pool that saturates under load. It writes code that's correct for a single request and completely wrong for a thousand simultaneous ones.&lt;/p&gt;

&lt;p&gt;My race condition? AI-generated async code where two transactions could hit the same resource within milliseconds. Each transaction was correct in isolation. Together, they interlock and corrupt shared state. Nothing in the code &lt;em&gt;looks&lt;/em&gt; wrong - the bug is in the timing that only exists under production load.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Doesn't Know Your Architecture
&lt;/h3&gt;

&lt;p&gt;Every Claude Code conversation starts fresh. Without context, it invents patterns. Feature A gets a service layer. Feature B gets business logic inline in the route handler. Feature C introduces a repository pattern nobody asked for. Each one works individually. Together, your codebase becomes an archaeology dig where every layer is a different civilization. This kind of architecture drift is how teams end up in the &lt;a href="https://dev.to/blog/rewrite-vs-refactor/"&gt;rewrite-vs-refactor debate&lt;/a&gt; that costs months instead of weeks.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Over-Engineers
&lt;/h3&gt;

&lt;p&gt;AI loves abstractions. Ask for a simple data processor and you'll get an AbstractBaseProcessor with a StrategyFactory and a PluginRegistry. Code that should be 30 lines becomes 150. AI doesn't feel the maintenance cost of abstraction - it just reaches for the most "proper" solution it's seen in training data.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Misses What's Between the Lines
&lt;/h3&gt;

&lt;p&gt;Business rules that nobody documented. The constraint that user IDs can't change after first payment because three downstream systems cache them. The convention that all async tasks must be idempotent because your queue has at-least-once delivery. AI can't know what it was never told, and the stuff that isn't written down is usually the stuff that matters most.&lt;/p&gt;

&lt;h3&gt;
  
  
  The False Confidence Trap
&lt;/h3&gt;

&lt;p&gt;This is the compounding problem. Because AI code is syntactically clean and well-structured, you trust it more than you should. You review it less carefully. You approve faster. And that's exactly when subtle bugs slip through - not because the AI is bad, but because the code &lt;em&gt;looks&lt;/em&gt; too good to question.&lt;/p&gt;

&lt;p&gt;These aren't reasons to stop using AI tools. They're reasons to use them with a system. The rest of this article is that system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    subgraph AI["What AI Generated"]
        A1["await db.get(sender)"]
        A2["await db.get(receiver)"]
        A3["if balance &amp;gt;= amount"]
        A4["sender.balance -= amount"]
        A5["await db.commit()"]
        A1 --&amp;gt; A2 --&amp;gt; A3 --&amp;gt; A4 --&amp;gt; A5
    end

    subgraph PROD["What Production Needed"]
        P1["select(...).with_for_update()"]
        P2["Lock rows in sorted order"]
        P3["if balance &amp;gt;= amount"]
        P4["sender.balance -= amount"]
        P5["await db.commit()"]
        P1 --&amp;gt; P2 --&amp;gt; P3 --&amp;gt; P4 --&amp;gt; P5
    end

    AI -. "Passes tests\nbut no row locking" .-&amp;gt; PROD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two Modes of Working With AI
&lt;/h2&gt;

&lt;p&gt;I've developed two distinct approaches for working with Claude Code in production. Neither eliminates the need to review AI output - both just change &lt;em&gt;when&lt;/em&gt; you invest your thinking time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mode 1: Inline Review (The Conversation)
&lt;/h3&gt;

&lt;p&gt;This is the interactive approach. I work with Claude Code on changes one at a time, reviewing and giving feedback as we go.&lt;/p&gt;

&lt;p&gt;The workflow looks like this: Claude suggests a change, I read it carefully, I either accept it, reject it, or ask for modifications. Then we move to the next change. It's slower per change, but I catch issues immediately when my mental context is still fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I review code changes and immediately give feedback. This is much slower but doesn't force me to review everything in one gigantic PR.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The trade-off is speed. You're not going to ship a feature in an hour with this approach. But you also won't face a 2,000-line PR full of AI-generated code that you need to audit all at once. The review burden is distributed across the work, not concentrated at the end.&lt;/p&gt;

&lt;p&gt;There's a hidden benefit: this mode is educational. I've learned new patterns, algorithms, and technologies through these conversations. When Claude suggests an approach I haven't seen before, I stop and understand it before accepting. Over time, that builds real expertise.&lt;/p&gt;

&lt;p&gt;I use this mode when I'm in unfamiliar territory - exploring a new API, debugging complex business logic, or working on code where the architecture isn't obvious yet. When I don't fully understand the problem space, I want to think through each step rather than delegate execution to AI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mode 2: Documentation-First (The Blueprint)
&lt;/h3&gt;

&lt;p&gt;This is the upfront investment approach. Before Claude writes a single line of code, I invest significant time creating detailed documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I invest time in creating very detailed documentation for the feature and then let AI work on tickets. Faster, but requires very clear understanding and plan before work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The documentation includes: the architecture (which services, which databases, how they interact), data flow (what comes in, how it transforms, what goes out), edge cases (what happens when X fails?), and constraints (performance requirements, backwards compatibility, security boundaries).&lt;/p&gt;

&lt;p&gt;Then I break the work into well-defined "tickets" and let Claude Code execute against that specification. For example, last week I needed a new API endpoint: I wrote a one-page spec covering the route, request/response schemas, database queries, error cases, and auth requirements. Claude produced a working implementation on the first pass - because it wasn't guessing, it was following a blueprint.&lt;/p&gt;

&lt;p&gt;This doesn't save me from all AI slop. I still review the results. But the code is more predictable, more consistent across features, and closer to what I actually wanted.&lt;/p&gt;

&lt;p&gt;I use this mode for well-understood features: CRUD operations, database migrations, repetitive refactoring, test writing. When I know exactly what needs to happen, documentation-first is faster overall despite the upfront time investment.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Use Which
&lt;/h3&gt;

&lt;p&gt;The decision is straightforward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Mode 1 (Inline Review) when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're in new or unfamiliar territory&lt;/li&gt;
&lt;li&gt;The problem requires complex business logic&lt;/li&gt;
&lt;li&gt;You're debugging and don't know the root cause yet&lt;/li&gt;
&lt;li&gt;The architecture isn't clear and you need to feel your way forward&lt;/li&gt;
&lt;li&gt;You're doing DevOps or infrastructure work (shell commands, network rules, Docker configs)&lt;/li&gt;
&lt;li&gt;The work unfolds as you go - you don't know what you'll find until you look&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use Mode 2 (Documentation-First) when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You understand the requirements completely&lt;/li&gt;
&lt;li&gt;The feature follows established patterns in your codebase&lt;/li&gt;
&lt;li&gt;You're doing repetitive work (migrations, similar CRUD endpoints)&lt;/li&gt;
&lt;li&gt;You're writing tests for well-understood behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I often alternate between them in the same day. Morning: documentation-first for a straightforward API endpoint. Afternoon: inline review for debugging a race condition where I don't know what's broken yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Key Insight
&lt;/h3&gt;

&lt;p&gt;Neither mode eliminates the need to review. The question isn't "should I review AI output?" The question is "when do I invest my thinking time?"&lt;/p&gt;

&lt;p&gt;Mode 1: thinking happens &lt;em&gt;during&lt;/em&gt; execution (distributed review).&lt;br&gt;
Mode 2: thinking happens &lt;em&gt;before&lt;/em&gt; execution (upfront design, then review at the end).&lt;/p&gt;

&lt;p&gt;Both require discipline. Both require saying no to AI suggestions that aren't right. The difference is timing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TB
    subgraph MODE1["Mode 1: Inline Review"]
        direction LR
        M1A["AI suggests change"] --&amp;gt; M1B["You review"] --&amp;gt; M1C["Accept / Reject / Modify"] --&amp;gt; M1A
        style M1B fill:#ffd,stroke:#aa0
    end

    subgraph MODE2["Mode 2: Documentation-First"]
        direction LR
        M2A["You write spec"] --&amp;gt; M2B["AI executes tickets"] --&amp;gt; M2C["You review result"]
        style M2A fill:#ffd,stroke:#aa0
    end

    MODE1 --- T1["Thinking distributed across work"]
    MODE2 --- T2["Thinking upfront, review at end"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Context Is Everything
&lt;/h2&gt;

&lt;p&gt;The single biggest factor in AI code quality isn't the model, the prompt, or the temperature setting. It's context. The more your AI tool knows about your project, your conventions, and your constraints, the better its output will be.&lt;/p&gt;

&lt;p&gt;I've found three layers of context that dramatically change Claude Code's output quality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: CLAUDE.md - Your Project's AI Constitution
&lt;/h3&gt;

&lt;p&gt;Claude Code reads a &lt;code&gt;CLAUDE.md&lt;/code&gt; file at the root of your project before every conversation. This is where you define the rules of engagement.&lt;/p&gt;

&lt;p&gt;Mine includes things like: "Never wrap code in try-except by default - we handle errors globally." And: "Do not use inline imports, always put imports at module level." These are my coding conventions, and without them Claude would happily generate code that violates both.&lt;/p&gt;

&lt;p&gt;CLAUDE.md should contain your coding style, architecture decisions, what NOT to do, and any project-specific constraints. Keep it concise - this isn't a wiki. It's a dense set of instructions that shapes every line of code Claude generates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Subdocument References
&lt;/h3&gt;

&lt;p&gt;CLAUDE.md gets long fast. The trick is keeping it lean and linking to deeper documentation for specific areas.&lt;/p&gt;

&lt;p&gt;My CLAUDE.md references separate docs for database schemas, API patterns, deployment procedures, and testing conventions. Claude reads the relevant subdocuments when it needs context for a specific task. This layered approach means CLAUDE.md stays scannable while deeper context is always available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: AI-Targeted Documentation
&lt;/h3&gt;

&lt;p&gt;This is the layer most people miss. Traditional documentation is written for humans - it explains concepts, includes tutorials, gives background. AI-targeted documentation is different. It's dense, specific, and architectural.&lt;/p&gt;

&lt;p&gt;Instead of "Our API uses REST principles," you write: "All API endpoints follow this pattern: FastAPI router in &lt;code&gt;api/v1/&lt;/code&gt;, Pydantic models in &lt;code&gt;schemas/&lt;/code&gt;, service layer in &lt;code&gt;services/&lt;/code&gt;. Responses use &lt;code&gt;StandardResponse&lt;/code&gt; wrapper. Auth via &lt;code&gt;get_current_user&lt;/code&gt; dependency."&lt;/p&gt;

&lt;p&gt;That one paragraph gives Claude more useful context than pages of explanation. The AI doesn't need to understand why - it needs to know what patterns to follow.&lt;/p&gt;

&lt;p&gt;The hidden cost: maintaining this documentation takes real effort. Every architecture change, every new convention needs to be reflected in these docs. It's not free. But it's the highest-ROI investment I've found for AI-assisted development. Better context in means dramatically better code out.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Principle
&lt;/h3&gt;

&lt;p&gt;Garbage context in, garbage code out. If your AI tool doesn't know your patterns, it will invent its own. If it doesn't know your constraints, it will ignore them. If it doesn't know your architecture, every feature will look different.&lt;/p&gt;

&lt;p&gt;Invest in documentation. Not for future developers - for your AI tools, right now. The payoff is immediate: more consistent code, fewer review cycles, less time fixing AI's guesses.&lt;/p&gt;

&lt;p&gt;There's a whole topic around automating the maintenance of AI documentation - keeping it fresh as your codebase evolves. I wrote &lt;a href="https://dev.to/blog/claude-md-guide"&gt;a definitive guide to CLAUDE.md&lt;/a&gt; covering the full five-layer configuration system. For now, start with CLAUDE.md and build from there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TB
    L1["CLAUDE.md\nConventions, architecture, constraints"]
    L2["Subdocuments\nDB patterns, API conventions, testing guide"]
    L3["AI-Targeted Feature Docs\nDense specs: routes, schemas, edge cases"]
    OUT["AI Output Quality"]

    L1 --&amp;gt;|"references"| L2
    L2 --&amp;gt;|"details"| L3
    L1 &amp;amp; L2 &amp;amp; L3 --&amp;gt;|"context in"| OUT

    style L1 fill:#e8f4fd,stroke:#1a73e8
    style L2 fill:#d4edda,stroke:#28a745
    style L3 fill:#fff3cd,stroke:#ffc107
    style OUT fill:#f8d7da,stroke:#dc3545
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Red Flags in Your Diff
&lt;/h2&gt;

&lt;p&gt;You already know why AI code goes wrong - runtime blindness, architecture drift, over-engineering. Here's what to actually look for when you're reviewing a diff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try-except wrapping everything.&lt;/strong&gt; The most reliable AI tell. You'll see entire function bodies wrapped in &lt;code&gt;try: ... except Exception: logger.error(...)&lt;/code&gt;. Your global error handler never fires because every exception gets caught and buried. If you see a bare &lt;code&gt;except Exception&lt;/code&gt; in a diff, reject it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Magic numbers.&lt;/strong&gt; Timeouts of 30, retry counts of 3, batch sizes of 100. AI picks numbers that look reasonable but aren't tied to anything real. Your timeout should be 5 seconds because the downstream SLA is 3. Check every numeric literal that isn't 0 or 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comments explaining "what."&lt;/strong&gt; &lt;code&gt;# Increment the counter&lt;/code&gt; above &lt;code&gt;counter += 1&lt;/code&gt;. If you see a comment describing what the next line does, delete it. The only comments worth keeping explain &lt;em&gt;why&lt;/em&gt; - and AI can't write those because it doesn't know your intent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Near-duplicate functions.&lt;/strong&gt; Three functions that differ by one parameter. AI generates each independently, doesn't see the duplication across sessions. Search for similar function signatures in the same module.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing boundary validation.&lt;/strong&gt; Internal functions that trust all inputs. AI treats each function as self-contained, skipping the validation that protects your system at entry points. Check: does new code that handles external input validate it?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline imports.&lt;/strong&gt; Imports inside function bodies instead of at module level. AI does this because it's "convenient" for the snippet. If your project convention is module-level imports, this is an instant reject.&lt;/p&gt;

&lt;p&gt;None of these require deep analysis. They're mechanical checks you can spot in seconds during review - which is exactly why they're worth having on a checklist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# RED FLAG: Try-except wrapping everything
# BEFORE - global error handler never fires
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to process order &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# AFTER - let exceptions propagate
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# RED FLAG: Magic numbers
# BEFORE
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# AFTER - tied to a real constraint
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DOWNSTREAM_TIMEOUT_SEC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 5s, SLA is 3s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Automating What You Can
&lt;/h2&gt;

&lt;p&gt;Every red flag from the previous section can be caught by a machine. Linters, type checkers, and pre-commit hooks handle the mechanical stuff - bare &lt;code&gt;except Exception&lt;/code&gt; blocks, inline imports, magic numbers, functions over 100 lines. You already have &lt;code&gt;ruff&lt;/code&gt; and &lt;code&gt;mypy&lt;/code&gt; (or ESLint and TypeScript strict). The step most people skip is adding custom pre-commit checks for the AI-specific patterns: error swallowing, numeric literals outside constants, near-duplicate functions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .pre-commit-config.yaml - AI-specific checks&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
  &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-bare-except&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no bare except Exception&lt;/span&gt;
      &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pygrep&lt;/span&gt;
      &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;except\s+(Exception|BaseException)\s*:'&lt;/span&gt;
      &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;python&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-inline-imports&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no inline imports&lt;/span&gt;
      &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pygrep&lt;/span&gt;
      &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^\s{4,}(import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;\S+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;)'&lt;/span&gt;
      &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;python&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The underrated CI trick: run your tests with concurrency. Most test suites run sequentially, so timing-dependent bugs pass locally. Run with &lt;code&gt;pytest -n auto&lt;/code&gt; or your framework's parallel flag. Bugs that only surface under concurrent execution will start failing in CI - which is exactly where you want them caught.&lt;/p&gt;

&lt;p&gt;But here's what matters more than any of this: &lt;strong&gt;what automation can't catch.&lt;/strong&gt; No tool will tell you whether this feature follows the same patterns as the rest of your codebase. No linter knows your business rules. No type checker can tell you the code solves the wrong problem.&lt;/p&gt;

&lt;p&gt;Automation handles the floor. Human review handles the ceiling. The mistake is confusing which problems belong to which layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Trade-offs
&lt;/h2&gt;

&lt;p&gt;Time for the part most AI articles skip.&lt;/p&gt;

&lt;h3&gt;
  
  
  What AI Is Genuinely Great At
&lt;/h3&gt;

&lt;p&gt;Boilerplate. CRUD endpoints, data models, serializers - anything where the pattern is established and you just need more of it. AI excels here because there's nothing to get wrong beyond following the template.&lt;/p&gt;

&lt;p&gt;Tests, when you define scope. Tell Claude exactly which cases to cover and it writes solid tests fast. Let it decide what to test and you'll get 90% happy-path coverage with zero edge cases.&lt;/p&gt;

&lt;p&gt;Mechanical refactoring. Renaming, moving code between modules, converting class patterns. Tedious work that humans mess up because our attention drifts. AI doesn't get bored.&lt;/p&gt;

&lt;p&gt;Exploring unfamiliar APIs. Need to integrate a library you've never used? Claude reads the docs faster than you and produces a working first draft. You still need to understand what it wrote, but the exploration phase shrinks dramatically.&lt;/p&gt;

&lt;p&gt;First-draft documentation. API docs, README updates, docstrings. AI produces a decent starting point that's faster to edit than to write from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  What AI Is Genuinely Bad At
&lt;/h3&gt;

&lt;p&gt;Everything that requires understanding beyond the code itself. Concurrency correctness, architecture decisions, implicit business rules, cross-codebase consistency - I covered these in detail earlier, and they remain the core risks.&lt;/p&gt;

&lt;p&gt;But the one I keep coming back to: AI doesn't push back. It's eager to please. Tell it to build something wrong and it will do so enthusiastically. You need to be the one who decides "we shouldn't build this." AI has no judgment about whether the task itself makes sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hidden Costs
&lt;/h3&gt;

&lt;p&gt;Maintaining AI-targeted documentation takes real effort. Every architecture change needs to be reflected in your CLAUDE.md and supporting docs. This is an ongoing tax, not a one-time setup.&lt;/p&gt;

&lt;p&gt;Review time sometimes exceeds writing time. For complex logic, reading and verifying AI output takes longer than writing it yourself would have. You save nothing - you just shifted the work from writing to reading.&lt;/p&gt;

&lt;p&gt;Context-switching between driving and reviewing is cognitively expensive. You're either thinking creatively or thinking critically. Switching between the two hundreds of times a day is draining in a way that pure coding isn't.&lt;/p&gt;

&lt;p&gt;The speed gain is real but smaller than the hype. I'm not 10x faster. I'm not even 5x faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  My Honest Assessment
&lt;/h3&gt;

&lt;p&gt;AI tools make me maybe 1.5-2x faster overall. The biggest gains are in exploration and boilerplate - not in the hard parts that actually matter. Core logic, architecture, debugging - these take the same time they always did, sometimes longer because I'm reviewing AI's work on top of my own thinking.&lt;/p&gt;

&lt;p&gt;Quality is maintained only because I refuse to skip review. If I stopped reviewing, I'd ship faster but sleep worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  You're the Architect, AI Is the Contractor
&lt;/h2&gt;

&lt;p&gt;AI coding tools are the most productive addition to my workflow in years. They're also the easiest way to accumulate technical debt I've ever seen. The difference between the two outcomes is discipline - not talent, not experience, just a system you follow consistently.&lt;/p&gt;

&lt;p&gt;You wouldn't hand a contractor a plot of land and say "build something." You'd give them blueprints, constraints, materials specs, and you'd inspect the work at every stage. AI tools are the same. You design. AI executes. You review. Skip any step and you're gambling.&lt;/p&gt;

&lt;p&gt;Here's what matters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invest in documentation - for your AI, not for future developers.&lt;/strong&gt; CLAUDE.md, subdocuments, AI-targeted architecture docs. Better context in, better code out. The payoff is immediate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose your review mode deliberately.&lt;/strong&gt; Inline review for the unknown. Documentation-first for the predictable. Match the mode to the work, not your mood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automate the mechanical checks.&lt;/strong&gt; Linters, type checkers, pre-commit hooks - let tools handle the floor so your review time goes to the ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know the limits honestly.&lt;/strong&gt; 1.5-2x faster, not 10x. Great at boilerplate, bad at concurrency. The speed is real. The hype isn't.&lt;/p&gt;

&lt;p&gt;If you want a concrete starting point: write a CLAUDE.md for your main project this week. Just your coding conventions, your architecture patterns, and three things AI should never do in your codebase. That single file will change the quality of every AI interaction you have.&lt;/p&gt;

&lt;p&gt;The race condition you don't catch today is the production incident you debug next week. And enough unchecked drift turns into the kind of &lt;a href="https://dev.to/blog/why-rewrites-fail/"&gt;big rewrite&lt;/a&gt; nobody wants. Build the system. Follow the system. Sleep well.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article was created with the assistance of Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>typescript</category>
      <category>react</category>
    </item>
    <item>
      <title>Build Your Own Passwordless OTP Auth on AWS Lambda</title>
      <dc:creator>Arseniy Potapov</dc:creator>
      <pubDate>Sat, 28 Feb 2026 10:10:03 +0000</pubDate>
      <link>https://dev.to/potapov/build-your-own-passwordless-otp-auth-on-aws-lambda-4eme</link>
      <guid>https://dev.to/potapov/build-your-own-passwordless-otp-auth-on-aws-lambda-4eme</guid>
      <description>&lt;p&gt;I was adding authentication to a side project and started evaluating managed auth services. &lt;a href="https://auth0.com/pricing" rel="noopener noreferrer"&gt;Auth0&lt;/a&gt; gives you 25,000 MAU free. &lt;a href="https://clerk.com/pricing" rel="noopener noreferrer"&gt;Clerk&lt;/a&gt; gives you 50,000. &lt;a href="https://aws.amazon.com/cognito/pricing/" rel="noopener noreferrer"&gt;Cognito&lt;/a&gt; gives you 10,000. For a side project, any of them would cost zero dollars.&lt;/p&gt;

&lt;p&gt;But all of them meant routing my auth through someone else's infrastructure. My users, my verification flow, my data - controlled by a third party. All I needed was for users to prove they own an email or phone number. No passwords, no social login, no user profiles. Just "enter your email, get a code, get a token."&lt;/p&gt;

&lt;p&gt;So I built one. Two Lambda functions, a DynamoDB table, and 180 lines of &lt;a href="https://dev.to/blog/python/"&gt;Python&lt;/a&gt;. It's been running in production since February 2023 and costs about a dollar a month.&lt;/p&gt;

&lt;p&gt;This article walks through the real code, the trade-offs, and a build-vs-buy framework so you can decide whether owning your auth stack is worth it - or whether a managed service is the right call.&lt;/p&gt;

&lt;p&gt;You can try the &lt;a href="https://otp.potapov.dev" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; and browse the &lt;a href="https://github.com/muzhig/simple-otp" rel="noopener noreferrer"&gt;source code on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How OTP Works
&lt;/h2&gt;

&lt;p&gt;Most OTP tutorials generate codes with &lt;code&gt;random.randint(100000, 999999)&lt;/code&gt;. That's not a one-time password - that's a random number with no cryptographic guarantees. An attacker who intercepts one code learns nothing about the next, but there's no mathematical relationship enforcing that. A proper OTP uses HOTP (HMAC-based One-Time Password), defined in &lt;a href="https://tools.ietf.org/html/rfc4226" rel="noopener noreferrer"&gt;RFC 4226&lt;/a&gt;. HOTP takes two inputs: a shared secret (a random base32 string, minimum 128 bits per the RFC) and a counter (an integer that increments). Run them through HMAC-SHA1 and dynamic truncation, and you get a 6-digit code that's cryptographically tied to that specific secret and counter value. The &lt;code&gt;pyotp&lt;/code&gt; library implements this algorithm, so you don't write any cryptography yourself - you pass in the secret and counter, and it gives you back a code.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random_base32&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;hotp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&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="c1"&gt;# first code
&lt;/span&gt;&lt;span class="n"&gt;hotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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="c1"&gt;# True - matches counter 0
&lt;/span&gt;&lt;span class="n"&gt;hotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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="c1"&gt;# False - wrong counter
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties make HOTP work for authentication:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Can't reuse.&lt;/strong&gt; Each code is tied to a specific counter value. Once verified, the record is deleted, so the same code never works again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can't predict.&lt;/strong&gt; Without the secret, there's no way to compute the next code. The secret never leaves the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can't go backwards.&lt;/strong&gt; A code generated for counter 5 doesn't work for counter 4.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  HOTP vs TOTP
&lt;/h3&gt;

&lt;p&gt;You've probably used TOTP - Time-based One-Time Password - with authenticator apps like Google Authenticator. TOTP is HOTP with time as the counter. Codes rotate every 30 seconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;HOTP (counter-based)&lt;/th&gt;
&lt;th&gt;TOTP (time-based)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code valid until&lt;/td&gt;
&lt;td&gt;Used or expired by TTL&lt;/td&gt;
&lt;td&gt;~30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use case&lt;/td&gt;
&lt;td&gt;"Send me a code" flows&lt;/td&gt;
&lt;td&gt;Authenticator apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clock sync needed&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User pressure&lt;/td&gt;
&lt;td&gt;None - enter when ready&lt;/td&gt;
&lt;td&gt;Must type within window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For "type your email, get a code" flows, HOTP is the right choice. TOTP would mean the user has 30 seconds to check their email and type the code - that's stressful and leads to failed attempts. With HOTP, the code stays valid until the record expires (5 minutes in my implementation) or the user enters it successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The whole system is four components: two Lambda functions, one DynamoDB table, and whichever delivery service you prefer for sending codes. I use Mailgun for email and Twilio for SMS.&lt;/p&gt;

&lt;p&gt;I chose &lt;a href="https://dev.to/blog/aws-choosing-right-services/"&gt;serverless&lt;/a&gt; because OTP verification is the definition of a bursty workload. Most of the time nobody is logging in. Then 50 people sign up after a Product Hunt launch and you need to handle them all. Lambda scales to zero when idle and handles bursts without provisioning anything. For a service that processes a few requests per day on average, paying for a running server would be wasteful.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Data Model
&lt;/h3&gt;

&lt;p&gt;Each OTP record lives in &lt;a href="https://dev.to/blog/types-of-databases/"&gt;DynamoDB&lt;/a&gt; with a TTL that auto-deletes expired codes. The &lt;code&gt;pynamodb&lt;/code&gt; ORM keeps the model definition clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pynamodb.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Model&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pynamodb.attributes&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UnicodeAttribute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NumberAttribute&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;simple-otp-secrets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UnicodeAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;otp_secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UnicodeAttribute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NumberAttribute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;expires&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NumberAttribute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; is the user's email or phone number - one record per identity. &lt;code&gt;otp_secret&lt;/code&gt; is the random base32 string that seeds HOTP generation. &lt;code&gt;counter&lt;/code&gt; tracks how many codes have been generated for this secret. &lt;code&gt;expires&lt;/code&gt; is a Unix timestamp for DynamoDB's TTL - after 5 minutes, DynamoDB deletes the record automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  What It Costs
&lt;/h3&gt;

&lt;p&gt;This is where serverless gets interesting. Here's what I actually pay:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lambda (2 functions)&lt;/td&gt;
&lt;td&gt;$0.00 (free tier: 1M requests/mo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB (on-demand)&lt;/td&gt;
&lt;td&gt;$0.00 (free tier: 25 GB storage, 25 WCU/RCU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway&lt;/td&gt;
&lt;td&gt;$0.00 (free tier: 1M calls/mo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailgun (email delivery)&lt;/td&gt;
&lt;td&gt;$0.00 (free tier: 100 emails/day)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio (SMS delivery)&lt;/td&gt;
&lt;td&gt;~$0.008 per SMS + $1.15/mo for a number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;&amp;lt;$2/mo&lt;/strong&gt; (or $0 if email-only)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only component that costs real money is Twilio SMS. If you only need email verification, the entire service runs within free tiers indefinitely. You could also replace Mailgun with &lt;a href="https://aws.amazon.com/ses/pricing/" rel="noopener noreferrer"&gt;AWS SES&lt;/a&gt; ($0.10 per 1,000 emails) and Twilio with &lt;a href="https://aws.amazon.com/sns/sms-pricing/" rel="noopener noreferrer"&gt;AWS SNS&lt;/a&gt; (~$0.007/SMS) to keep everything in AWS - I used Mailgun and Twilio because I already had accounts, but SES would make the email path truly $0. I've been running this for three years and my AWS bill has never exceeded $0.50 in a single month.&lt;/p&gt;

&lt;p&gt;Deployment uses the Serverless Framework, which wraps CloudFormation and handles the API Gateway + Lambda wiring. Here's the core of &lt;code&gt;serverless.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otpVerificationStart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main.otp_verification_start&lt;/span&gt;
    &lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/otp-verification/start"&lt;/span&gt;
          &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
          &lt;span class="na"&gt;cors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;reservedConcurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;

  &lt;span class="na"&gt;otpVerificationComplete&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main.otp_verification_complete&lt;/span&gt;
    &lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/otp-verification/complete"&lt;/span&gt;
          &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
          &lt;span class="na"&gt;cors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;reservedConcurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;

&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;simpleOtpSecrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::DynamoDB::Table&lt;/span&gt;
      &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;simple-otp-secrets&lt;/span&gt;
        &lt;span class="na"&gt;AttributeDefinitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
            &lt;span class="na"&gt;AttributeType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;S&lt;/span&gt;
        &lt;span class="na"&gt;KeySchema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
            &lt;span class="na"&gt;KeyType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HASH&lt;/span&gt;
        &lt;span class="na"&gt;ProvisionedThroughput&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ReadCapacityUnits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;WriteCapacityUnits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="na"&gt;TimeToLiveSpecification&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;expires&lt;/span&gt;
          &lt;span class="na"&gt;Enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two functions, one DynamoDB table with TTL enabled, IAM permissions for DynamoDB access. First deploy takes about 3 minutes. Subsequent deploys take under 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Endpoints
&lt;/h2&gt;

&lt;p&gt;The entire service is two Lambda functions behind API Gateway. One creates the OTP and sends it. The other verifies it and issues a JWT.&lt;/p&gt;

&lt;h3&gt;
  
  
  /start - Create and Send the Code
&lt;/h3&gt;

&lt;p&gt;When a user requests a code, the start endpoint creates or resets an OTP record in DynamoDB, generates the next HOTP code, and sends it via Mailgun (email) or Twilio (SMS).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;otp_verification_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queryStringParameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\D&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;otp_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;otp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DoesNotExist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;otp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;otp_secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random_base32&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;otp_secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.mailgun.net/v3/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MAILGUN_DOMAIN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAILGUN_API_KEY&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                      &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FROM_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Verify your email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your PIN: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.twilio.com/2010-04-01/Accounts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TWILIO_SID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/Messages.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TWILIO_SID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TWILIO_TOKEN&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                      &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;To&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;From&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TWILIO_NUMBER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your PIN: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a record already exists for this email or phone, I increment the counter instead of creating a new secret. This means the previous code is immediately invalidated - &lt;code&gt;pyotp.HOTP.verify()&lt;/code&gt; checks against the exact counter value, so only the latest code works. If a user requests a second code before entering the first one, they need to use the new code.&lt;/p&gt;

&lt;p&gt;The Mailgun and Twilio calls are raw HTTP requests. No SDK. For a function this small, pulling in &lt;code&gt;boto3&lt;/code&gt; or the Twilio SDK would double the deployment package for no benefit.&lt;/p&gt;

&lt;h3&gt;
  
  
  /complete - Verify and Issue JWT
&lt;/h3&gt;

&lt;p&gt;When the user enters their code, the complete endpoint looks up their OTP record, verifies the HOTP code, deletes the record, and returns a signed JWT.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;otp_verification_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queryStringParameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;pin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;otp_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\D&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;otp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises DoesNotExist if expired or never created
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;otp_secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid PIN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tel:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://otp.potapov.dev/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.potapov.dev/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;success_response&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I delete the record on successful verification rather than incrementing the counter. This prevents code reuse and avoids stale records piling up in DynamoDB. If verification fails, the record stays - the user can try again with the same code until TTL expires.&lt;/p&gt;

&lt;p&gt;One shortcut I should be honest about: rate limiting. RFC 4226 explicitly requires throttling to resist brute force attacks - a 6-digit code has only a million possible values. I handle this with Lambda's &lt;code&gt;reservedConcurrency&lt;/code&gt; set to 1 per function, which you can see in the &lt;code&gt;serverless.yml&lt;/code&gt; above. That's not real rate limiting per user - it's a concurrency cap that serializes requests to the endpoint, but doesn't stop a patient attacker from trying codes sequentially for a single email. For a personal demo that handles a few logins per day, it's acceptable. For anything beyond that, you'd want per-IP or per-user throttling at the API Gateway level, or a DynamoDB counter that locks out after 3 failed attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  JWT: From Code to Client
&lt;/h2&gt;

&lt;p&gt;After successful OTP verification, the service issues a signed JWT. This token is how downstream APIs know the user proved ownership of their email or phone number.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying on the Receiving End
&lt;/h3&gt;

&lt;p&gt;The /complete endpoint issues the token. The interesting part is what happens when a downstream API receives it. Here's what validation looks like:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.potapov.dev/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://otp.potapov.dev/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# "email:user@example.com"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;algorithms&lt;/code&gt; parameter is required in PyJWT 2.x - omitting it raises &lt;code&gt;DecodeError&lt;/code&gt;. The &lt;code&gt;audience&lt;/code&gt; and &lt;code&gt;issuer&lt;/code&gt; parameters enforce that the token was meant for this specific API and issued by our OTP service. If either doesn't match, PyJWT raises an exception before your code ever sees the claims. The 24-hour expiry is generous for a demo - in production I'd cut it to 1-4 hours depending on the use case.&lt;/p&gt;

&lt;p&gt;One Python detail worth noting: the &lt;code&gt;datetime.now(timezone.utc)&lt;/code&gt; call in the /complete endpoint (with &lt;code&gt;from datetime import datetime, timedelta, timezone&lt;/code&gt;). If you're reading older tutorials that use &lt;code&gt;datetime.utcnow()&lt;/code&gt;, that's deprecated since Python 3.12 and now emits a &lt;code&gt;DeprecationWarning&lt;/code&gt;. The old version returns a naive datetime, the new one returns a timezone-aware datetime. PyJWT 2.x handles both, but the new form is correct and won't spam your logs with warnings.&lt;/p&gt;

&lt;h3&gt;
  
  
  HS256 vs RS256
&lt;/h3&gt;

&lt;p&gt;I use HS256 (symmetric signing) because this is a single-service setup. The same secret that signs the token also verifies it. Simple, fast, one environment variable.&lt;/p&gt;

&lt;p&gt;The limitation: only services that know the &lt;code&gt;JWT_SECRET&lt;/code&gt; can verify tokens. If I wanted the OTP service to act as a third-party identity provider - where other apps verify tokens without knowing the signing key - I'd switch to RS256. RS256 uses a private key to sign and a public key to verify. You publish the public key, and any service can validate tokens without accessing secrets.&lt;/p&gt;

&lt;p&gt;For a side project or internal tool, HS256 is the right call. For a product where external services consume your tokens, RS256 is worth the extra setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Client-Side Decoding
&lt;/h3&gt;

&lt;p&gt;JWTs are signed, not encrypted. The payload is base64-encoded JSON that any client can read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jwtDecode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jwt-decode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jwtDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "email:user@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's by design. The client needs to know who's logged in without making a server round-trip. But it means you should never put sensitive data in JWT claims - anything in the payload is readable by anyone with the token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build vs Buy
&lt;/h2&gt;

&lt;p&gt;Every OTP tutorial skips this part. They show you how to build it, declare victory, and leave you to figure out whether you should have used Auth0 instead. I've run this service for three years alongside projects that use Cognito and Clerk. Here's when each makes sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Build Your Own
&lt;/h3&gt;

&lt;p&gt;Build your own OTP service when all of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your auth needs are simple: email or phone verification, nothing else.&lt;/li&gt;
&lt;li&gt;You don't need SSO, SAML, or social login.&lt;/li&gt;
&lt;li&gt;You're comfortable deploying to Lambda (or any serverless platform).&lt;/li&gt;
&lt;li&gt;You want to own your auth stack. No vendor can change pricing, deprecate an API, or sunset a feature you depend on. Your data stays in your AWS account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cost isn't the main reason to build - managed services have &lt;a href="https://auth0.com/pricing" rel="noopener noreferrer"&gt;generous free tiers&lt;/a&gt; now. The real advantage is simplicity and control. When something breaks (and it will, eventually), you can read every line of the service in 10 minutes. Try doing that with Cognito's documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Buy
&lt;/h3&gt;

&lt;p&gt;Buy a managed auth service when any of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need MFA beyond OTP (FIDO2, passkeys, authenticator apps).&lt;/li&gt;
&lt;li&gt;Your customers require SSO/SAML integration.&lt;/li&gt;
&lt;li&gt;You have compliance requirements (SOC 2, HIPAA, GDPR consent flows).&lt;/li&gt;
&lt;li&gt;You're building a team product where auth touches user roles, permissions, organizations.&lt;/li&gt;
&lt;li&gt;You'd rather not think about auth at all - the &lt;a href="https://clerk.com/pricing" rel="noopener noreferrer"&gt;free tiers&lt;/a&gt; are generous enough that cost isn't a factor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wouldn't use this OTP service for a B2B SaaS product. Enterprise customers expect SSO. Security auditors expect documented auth flows, not a Lambda function someone built on a weekend. The moment you need "forgot password" or "link social account" or "enforce password rotation," you're reinventing a wheel that Auth0 and Clerk have spent years refining.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Decision
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criteria&lt;/th&gt;
&lt;th&gt;Build (DIY OTP)&lt;/th&gt;
&lt;th&gt;Buy (&lt;a href="https://auth0.com/pricing" rel="noopener noreferrer"&gt;Auth0&lt;/a&gt;/&lt;a href="https://aws.amazon.com/cognito/pricing/" rel="noopener noreferrer"&gt;Cognito&lt;/a&gt;/&lt;a href="https://clerk.com/pricing" rel="noopener noreferrer"&gt;Clerk&lt;/a&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost (low traffic)&lt;/td&gt;
&lt;td&gt;&amp;lt;$1&lt;/td&gt;
&lt;td&gt;$0 (free tiers: 10-50K users)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost at scale&lt;/td&gt;
&lt;td&gt;Scales with SMS only&lt;/td&gt;
&lt;td&gt;$35-240+/mo for paid features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;2-4 hours&lt;/td&gt;
&lt;td&gt;30 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO/SAML&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MFA options&lt;/td&gt;
&lt;td&gt;OTP only&lt;/td&gt;
&lt;td&gt;OTP, FIDO2, passkeys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor lock-in&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Medium to high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance certifications&lt;/td&gt;
&lt;td&gt;You own it&lt;/td&gt;
&lt;td&gt;Provider handles it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance burden&lt;/td&gt;
&lt;td&gt;Near zero (serverless)&lt;/td&gt;
&lt;td&gt;Near zero (managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customization&lt;/td&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;Limited by provider&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest answer is that most teams should buy. Auth is a solved problem, and the managed services handle edge cases (account recovery, brute force protection, session management) that you'll eventually need. I kept running my own because the cost is negligible and I like owning the stack. That's a preference, not a recommendation.&lt;/p&gt;

&lt;p&gt;There's a grey area worth mentioning: projects that start with simple OTP and grow. You build this for your MVP, users love it, now you need "remember this device" and "sign in with Google" and "admin can revoke sessions." At that point you're building an auth platform, not an OTP service. The right move is to migrate to a managed provider before you've reinvented half of it. I've seen teams spend months adding features to DIY auth that Auth0 ships out of the box.&lt;/p&gt;

&lt;p&gt;If you're a solo founder building an MVP, a side project, or anything where "user enters email, gets a code, gets a token" is the entire auth story - building takes an afternoon and costs nothing. The moment auth becomes a feature instead of plumbing, switch to a managed service and spend your time on what makes your product different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Years in Production
&lt;/h2&gt;

&lt;p&gt;I deployed this service in February 2023. It's now 2026 and it's still running. Here's what that looks like in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Went Right
&lt;/h3&gt;

&lt;p&gt;The service has had exactly zero outages that I caused. Lambda functions don't go stale, DynamoDB tables don't need vacuuming, and there's no server to patch. I haven't SSH'd into anything because there's nothing to SSH into. The only maintenance I've done in three years is updating the Mailgun sending domain when I moved to a new domain registrar.&lt;/p&gt;

&lt;p&gt;Total operational cost over three years: under $30, almost entirely Twilio SMS charges. The AWS components have never exceeded free tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Went Wrong
&lt;/h3&gt;

&lt;p&gt;Twice, Mailgun rate-limited my sending domain because I was on the free tier and hit the 100 emails/day cap during a demo. Not a code problem - just a free tier limit I should have anticipated. I've since switched to a self-hosted mail server, but &lt;a href="https://aws.amazon.com/ses/pricing/" rel="noopener noreferrer"&gt;AWS SES&lt;/a&gt; would have been the simpler fix.&lt;/p&gt;

&lt;p&gt;Once, a DynamoDB TTL deletion was delayed by about 15 minutes (TTL is best-effort, not exact), which meant an expired code still technically existed in the database. The HOTP verification still failed because the counter didn't match, so no security issue - just a confusing "OTP code not found" vs "Invalid PIN" error message distinction.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'd Change
&lt;/h3&gt;

&lt;p&gt;If I were hardening this for production use beyond my own projects, four things would change.&lt;/p&gt;

&lt;p&gt;I'd switch to RS256 for JWT signing, for the reasons I described above - the moment a second service needs to verify tokens, symmetric signing becomes a liability. I'd also add real per-user rate limiting instead of the &lt;code&gt;reservedConcurrency&lt;/code&gt; workaround I mentioned in the endpoints section. A DynamoDB counter that locks out after 3 failed attempts per email is straightforward and would actually satisfy the RFC 4226 throttling requirement.&lt;/p&gt;

&lt;p&gt;The error responses need work. Right now they're generic ("Invalid PIN", "OTP code not found"). Distinguishing between "expired" and "never created" helps the frontend show the right UX - "Your code expired, request a new one" is more useful than "something went wrong."&lt;/p&gt;

&lt;p&gt;And I'd wrap the OTP secret with KMS before storing it. DynamoDB encrypts at rest by default, so it's not sitting unencrypted on disk, but application-level encryption with KMS would add negligible cost and measurable security.&lt;/p&gt;

&lt;p&gt;None of these are deal-breakers for a personal demo. They're the kind of improvements that matter when the service grows beyond "my side project" into "something other people depend on."&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;p&gt;The whole service is 180 lines of Python, two Lambda functions, and one DynamoDB table. It handles email and SMS verification with proper HOTP codes, issues signed JWTs, and cleans up after itself. I've been running it for three years without touching it.&lt;/p&gt;

&lt;p&gt;Should you build this? If your app needs auth and your budget is zero, you now have working code. If your app needs auth and your budget isn't zero, you now know exactly when a managed service is worth it - and you can make that call based on features you actually need, not on the assumption that auth is too hard to own.&lt;/p&gt;

&lt;p&gt;Fork the &lt;a href="https://github.com/muzhig/simple-otp" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, try the &lt;a href="https://otp.potapov.dev" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; to see the flow in action, and decide for yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article was created with the assistance of Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>backend</category>
      <category>prototype</category>
    </item>
    <item>
      <title>AI Data Cleaning: From Demo to Production</title>
      <dc:creator>Arseniy Potapov</dc:creator>
      <pubDate>Sat, 14 Feb 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/potapov/ai-data-cleaning-in-production-from-toy-demos-to-real-pipelines-m17</link>
      <guid>https://dev.to/potapov/ai-data-cleaning-in-production-from-toy-demos-to-real-pipelines-m17</guid>
      <description>&lt;p&gt;In December 2022, I wrote an article about using AI to clean data. I was excited. GPT-3 had just become accessible, &lt;code&gt;text-davinci-003&lt;/code&gt; could parse messy CSV rows, and I built a demo with 6 rows of sample data. Six rows. I showed how the model could normalize dates, fix capitalization, and extract structured fields from free text. It worked beautifully.&lt;/p&gt;

&lt;p&gt;It also didn't scale past a demo.&lt;/p&gt;

&lt;p&gt;The model cost $0.02 per 1,000 tokens. Rate limits capped throughput at a few hundred requests per minute. Every API call returned slightly different formatting. There was no validation, no error handling, no way to reproduce results. I was essentially asking the world's most expensive intern to hand-clean each row one at a time.&lt;/p&gt;

&lt;p&gt;Three years later, I build data ingestion systems that process millions of records from dozens of sources, each arriving in its own special flavor of broken. County tax rolls with names like "SMITH JOHN A JR &amp;amp; MARY B SMITH-JONES TTEE OF THE SMITH FAMILY TRUST." Address files where column B switches meaning halfway through. CSV exports with mixed encodings, missing headers, and creative interpretations of what "null" means.&lt;/p&gt;

&lt;p&gt;AI is central to how these systems work. But not the way I imagined in 2022. The LLM doesn't touch every row. It looks at a sample, figures out what's wrong, creates a transformation plan, and hands it off to &lt;code&gt;pandas&lt;/code&gt; and Pydantic to execute and validate. The expensive, intelligent part happens once. The cheap, deterministic part runs on every row.&lt;/p&gt;

&lt;p&gt;This is the article I should have written three years ago. Here's what actually works when you're cleaning data at production scale - not with 6 rows, but with millions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Customer's Data Is Broken in Its Own Way
&lt;/h2&gt;

&lt;p&gt;Clean datasets are all alike; every dirty dataset is dirty in its own way.&lt;/p&gt;

&lt;p&gt;I've processed data from county governments, mortgage companies, title agencies, and tax assessors across 14 states. Not one source arrived clean. The problems fall into predictable categories, but the specific combination is unique every time.&lt;/p&gt;

&lt;p&gt;Start with the structural problems. CSV files with no headers, mixed delimiters (tabs in one section, commas in another), multiline fields that break naive parsers. One county sends an Excel workbook with 6 tabs. Another sends a ZIP of CSVs named &lt;code&gt;EXPORT_FINAL_v2_CORRECTED(1).csv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then there's encoding. Latin-1 data in a UTF-8 world. Windows line endings mixed with Unix. BOM markers that show up as invisible characters. I once spent a day debugging why 200 property addresses contained &lt;code&gt;Ã©&lt;/code&gt; instead of &lt;code&gt;é&lt;/code&gt; - classic UTF-8 double-encoding from an intermediate system that converted the file twice.&lt;/p&gt;

&lt;p&gt;Semantic ambiguity is worse. "John Smith" in one source, "SMITH, JOHN A JR" in another, "SMITH JOHN &amp;amp; MARY TTEE" in a third. Same person, three representations. Dates arrive in 15+ formats: &lt;code&gt;01/02/2024&lt;/code&gt;, &lt;code&gt;2024-01-02&lt;/code&gt;, &lt;code&gt;Jan 2, 2024&lt;/code&gt;, &lt;code&gt;20240102&lt;/code&gt;, and my favorite, &lt;code&gt;1/2/24&lt;/code&gt; (is that January 2nd or February 1st?).&lt;/p&gt;

&lt;p&gt;Missing data has personality. Empty string, "N/A", "n/a", "NA", "NULL", "None", "-", "0", and a single space character - I've seen all of these mean "this field has no value" within the same file. Then there are the blanket deletions where an entire column is empty because the export process silently dropped it.&lt;/p&gt;

&lt;p&gt;And domain-specific landmines. Addresses like "123 MAIN ST APT 4B C/O J SMITH" that need to be split into street, unit, and care-of fields. Property class codes that mean different things in different counties ("01" is "Single Family" in Lee County, "Residential" in Broward, and "Vacant Land" in Marion).&lt;/p&gt;

&lt;p&gt;The problems are predictable by category but unique in combination. You can't write one script that handles all sources. And you can't hire enough people to manually map every new dataset - not when each one arrives with its own encoding, its own column names, its own creative interpretation of how to represent a null value.&lt;/p&gt;

&lt;p&gt;The question is what you do about it. People have tried a lot of things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What People Try Before AI
&lt;/h2&gt;

&lt;p&gt;Every approach to data cleaning exists for a reason, and most of them work fine within their limits. The trouble starts when you outgrow those limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual cleanup in Excel.&lt;/strong&gt; Power Query, VLOOKUP, find-and-replace. This handles one-off tasks with small files perfectly well. If you're fixing 200 rows once a quarter, Excel is the right tool. It's not reproducible, though. Next quarter, when the same file arrives with a new encoding issue, you start from scratch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-off Python scripts.&lt;/strong&gt; A developer writes a &lt;code&gt;pandas&lt;/code&gt; pipeline per data source. I've done this dozens of times: read CSV, rename columns, parse dates, strip whitespace, export. Works great for 5-10 sources. At 50+, you're maintaining 50 scripts, each encoding tribal knowledge that walks out the door when the developer leaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ETL platforms&lt;/strong&gt; like &lt;code&gt;dbt&lt;/code&gt;, Fivetran, or Airbyte are excellent for structured sources: APIs, databases, well-defined schemas. They struggle with truly messy data. The CSV with no headers. The Excel file where column B is sometimes "Owner Name" and sometimes "Property Address" depending on which county clerk exported it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data quality frameworks&lt;/strong&gt; (Great Expectations, Soda, &lt;code&gt;dbt&lt;/code&gt; tests) are superb at &lt;em&gt;detecting&lt;/em&gt; problems: "15% of addresses failed to geocode." They don't &lt;em&gt;fix&lt;/em&gt; anything. You still need a human to figure out why and write the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pasting into ChatGPT&lt;/strong&gt; is where I was in 2022. Send 50 rows, ask for clean output, marvel at the result. It genuinely works for small batches. It doesn't scale: Osmos measured $2,250 for 100K rows through GPT-4 before Microsoft acquired them. Inconsistent output between calls, no validation, and rate limits that kill any automated pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI-assisted pipeline&lt;/strong&gt; is what I'll spend the rest of this article on. AI analyzes samples, generates transformation configs, quality gates verify the output. It combines deterministic execution from scripts, config-driven structure from ETL, automated checks from quality frameworks, and LLMs' ability to understand messy, ambiguous data.&lt;/p&gt;

&lt;p&gt;Here's how they compare:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Scales to 1M rows&lt;/th&gt;
&lt;th&gt;Reproducible&lt;/th&gt;
&lt;th&gt;Handles ambiguity&lt;/th&gt;
&lt;th&gt;Cost per run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Excel / manual&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Human judgment&lt;/td&gt;
&lt;td&gt;Free + hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-off scripts&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Per-script&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Dev time per source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ETL platforms&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Platform fees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality frameworks&lt;/td&gt;
&lt;td&gt;Detection only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Config time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paste into ChatGPT&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (LLM)&lt;/td&gt;
&lt;td&gt;$2-2,250 per batch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI-assisted pipeline&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (LLM + rules)&lt;/td&gt;
&lt;td&gt;Hours once, pennies per run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these approaches is universally wrong. Excel is perfect for one-off cleanup. Scripts are perfect for stable, well-understood sources. ETL platforms are perfect for structured pipelines. The AI-assisted approach fills the gap between them: understanding messy, ambiguous data and generating the transformation logic that traditional tools then execute.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI as Planner, Not Processor
&lt;/h2&gt;

&lt;p&gt;The pattern that works in production is simple to describe: AI looks at a sample of your data, creates a transformation plan, and the system executes that plan with traditional tools. The LLM touches 100 rows. &lt;code&gt;pandas&lt;/code&gt; processes 10 million.&lt;/p&gt;

&lt;p&gt;Here's the concrete version. Five steps, each doing one thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Define the target shape.&lt;/strong&gt; Before any AI gets involved, you need a contract - what should clean data look like? I use Pydantic models for this because they double as validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PropertyRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parcel_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unique parcel identifier, e.g. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;12-34-56-789&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;owner_first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;owner_last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;zip_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^\d{5}(-\d{4})?$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;assessed_value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;is_homestead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This schema isn't just documentation. It's the success criterion. After cleaning, every row must validate against it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Describe expectations.&lt;/strong&gt; Give the AI context about the data - not the schema (that's step 1), but &lt;em&gt;what the data represents&lt;/em&gt;. "This is a property tax roll export from a county government. Each row is a parcel. Owners can be individuals, trusts, or corporations. Addresses include both mailing and property-site addresses."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Feed AI sample data plus a toolkit.&lt;/strong&gt; Send the LLM 50-100 representative rows, the target schema, and a list of available transformation operations: &lt;code&gt;rename_column&lt;/code&gt;, &lt;code&gt;parse_date&lt;/code&gt;, &lt;code&gt;split_name&lt;/code&gt;, &lt;code&gt;filter_rows&lt;/code&gt;, &lt;code&gt;map_values&lt;/code&gt;, &lt;code&gt;convert_type&lt;/code&gt;. The LLM doesn't need to implement these - it just needs to know they exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: AI generates the plan.&lt;/strong&gt; The LLM responds with an ordered list of transformations. In our production system, this is a JSON config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transforms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rename"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mapping"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"OWNERNAME1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner_raw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"SITUS_ADDR"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"map_values"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prop_class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"mapping"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"01"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Single Family"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"02"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mobile Home"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"03"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Condo"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"split_name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner_raw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"targets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"owner_first"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner_last"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trust_flag"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"filter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"address_type == 'situs'"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"convert"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assessed_value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"float"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"null_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This config could be a Python script, a SQL query, or a &lt;code&gt;dbt&lt;/code&gt; model. The format matters less than the pattern: it's declarative, versionable, and deterministic. Same input always produces same output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Validate, execute, monitor.&lt;/strong&gt; Run the plan on a subset. Check quality gates (more on those next). If gates fail, send the failures back to the AI and let it adjust the plan. Once the subset passes, run on the full dataset. Check gates again.&lt;/p&gt;

&lt;p&gt;This is why it scales. The expensive part (LLM analysis) happens once per data source. The cheap part (&lt;code&gt;pandas&lt;/code&gt; applying the config) runs on every row. When the same county sends next month's data in the same format, the config runs without any AI involvement at all. When a new county arrives with a completely different layout, AI generates a new config.&lt;/p&gt;

&lt;p&gt;In our production system, we run 100+ county configurations. Each one was originally created by a human engineer studying the data. But the process that human follows - look at samples, understand the schema, write the mapping - is exactly what an AI agent can automate. The engineer's job shifts from "write the config" to "review the AI's config and approve it."&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Know the Data Is Actually Clean?
&lt;/h2&gt;

&lt;p&gt;You don't trust AI-generated code without tests. You shouldn't trust AI-generated transformation plans without quality gates either. The principle is the same: automate the work, verify the result.&lt;/p&gt;

&lt;p&gt;In our production pipeline, every data load runs through four layers of checks before anything hits the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema gates&lt;/strong&gt; are the baseline. Every field matches its expected type. IDs are unique. Required fields aren't null. Dates parse to ISO format. Foreign keys point at existing records. If your Pydantic model says &lt;code&gt;zip_code&lt;/code&gt; must match &lt;code&gt;^\d{5}$&lt;/code&gt; and 3% of rows have "N/A" in that field, the gate catches it before those rows propagate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shape gates&lt;/strong&gt; catch structural problems. On a refresh (re-processing the same data source), parcel count shouldn't change by more than 10%. If it does, someone uploaded the wrong file, or the schema changed, or data got truncated. We flag anything above 5% as a warning and above 10% as an error that blocks ingestion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution gates&lt;/strong&gt; are where it gets interesting. After parsing 50,000 owner names, we check the top 20 most common first names. If "SMITH" appears as a first name, the parser swapped first and last name columns. If more than 5 of the top 20 first names are uncommon (not in a standard frequency list), the parser is splitting names wrong - probably treating "JOHN MICHAEL" as first="JOHN MICHAEL" instead of first="JOHN", middle="MICHAEL."&lt;/p&gt;

&lt;p&gt;We run similar checks on address geocoding: after standardizing addresses through an address validation API (we use SmartyStreets), at least 60% should validate. Below 40% means a systematic parsing problem - wrong column, encoding mismatch, or format the parser doesn't recognize. On exemption rates, 25-40% of residential parcels typically have a homestead exemption. Below 10% means exemption data is missing entirely. Above 60% means the parser is incorrectly flagging non-exempt parcels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business logic gates&lt;/strong&gt; check relationships across tables. Every owner record must reference an existing parcel. Every transaction must reference an existing parcel. Exemption codes must exist in the reference table. These catch join errors and mapping mistakes that look fine in isolation but break when you query across tables.&lt;/p&gt;

&lt;p&gt;The thresholds (10%, 40%, 60%) come from processing 100+ data sources over several years. They encode what "normal" looks like. Data is never 100% accurate - there's always noise, always edge cases. The goal isn't perfection. It's catching &lt;em&gt;systematic&lt;/em&gt; problems early, before bad data propagates through the system and produces wrong results downstream.&lt;/p&gt;

&lt;p&gt;Here's the part that connects to the AI pipeline: when a gate fails, the system doesn't crash. It reports which gate failed, which rows caused the failure, and what looks wrong. That report feeds back to the AI agent, which can analyze the failures and adjust the transformation plan. Human reviews the adjustment, approves, and the pipeline reruns. This is the feedback loop that makes the system trustworthy: AI generates the plan, gates verify it, failures get fixed, and each iteration improves the result.&lt;/p&gt;

&lt;p&gt;This isn't academic idealism. &lt;a href="https://arxiv.org/pdf/2512.04123" rel="noopener noreferrer"&gt;Research on production agent deployments&lt;/a&gt; shows that 68% need human intervention within 10 steps. The best AI systems aren't fully autonomous - they're semi-autonomous with clear checkpoints. Quality gates are those checkpoints for data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name Parsing - Where the Hybrid Pattern Clicks
&lt;/h2&gt;

&lt;p&gt;Let me show you the "AI as planner" pattern with a concrete case study.&lt;/p&gt;

&lt;p&gt;Our data has a &lt;code&gt;full_name&lt;/code&gt; field. It contains everything from "John Smith" to "SMITH, JOHN A JR &amp;amp; MARY B SMITH-JONES TTEE OF THE SMITH FAMILY TRUST DATED 01/01/2020." We need to extract: first name, last name, trust flag, corporate flag. For every record.&lt;/p&gt;

&lt;p&gt;The traditional approach is a regex-based parser. Ours handles the common patterns well: "JOHN SMITH," "SMITH, JOHN A JR," "DR JOHN MICHAEL SMITH," Hispanic compounds like "DE LA CRUZ." It scores each parse result with a confidence value based on how well the tokens match known name patterns.&lt;/p&gt;

&lt;p&gt;About 80% of names parse cleanly with high confidence. That's 40,000 out of 50,000 records handled instantly by deterministic code. No AI involved, no API cost, no latency.&lt;/p&gt;

&lt;p&gt;The remaining 20% is where regex breaks down. Trust names: "DEBBIE DAVIDSON REVOCABLE TRUST." Multiple owners in a trust: "WALSH JOSEPH T &amp;amp; NHUNG REVOCABLE TRUST." Corporate entities: "M &amp;amp; M MANAGEMENT LLC." Names where context matters: is "LE" a Vietnamese surname or a legal abbreviation?&lt;/p&gt;

&lt;p&gt;These low-confidence results get routed to an LLM. The prompt includes the raw name string, the target schema (first_name, last_name, trust_flag, is_corporate), and a few examples of correct parsing. The LLM handles the ambiguity that regex can't.&lt;/p&gt;

&lt;p&gt;Out of those 10,000 LLM-routed names, roughly 9,500 resolve cleanly. The remaining 500 get flagged for human review - a queue that a person can clear in a few hours instead of manually fixing all 10,000.&lt;/p&gt;

&lt;p&gt;The cost math: the LLM portion costs about $2 for 10,000 names. The alternative is hiring someone to manually parse 10,000 complex names, which takes days.&lt;/p&gt;

&lt;p&gt;The pattern generalizes beyond names. Any data cleaning task where 80% is predictable and 20% is ambiguous fits this model: let traditional tools handle the easy cases, route the hard cases to AI, validate everything against the schema, and flag what neither can handle. You're not replacing the regex parser. You're adding a second tier that handles what the regex parser can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop Copy-Pasting Into ChatGPT
&lt;/h2&gt;

&lt;p&gt;There's a difference between using AI as a chat tool and using AI as a worker. Most "AI data cleaning" tutorials show the chat version: open ChatGPT, paste 50 rows, ask it to clean them, copy the result back. That works for one-off tasks the way manual Excel cleanup works - fine for small, non-recurring jobs.&lt;/p&gt;

&lt;p&gt;The production version looks completely different from the user's perspective. Here's what actually happens when someone uploads data to our system:&lt;/p&gt;

&lt;p&gt;A user receives a link. They drop their files - a ZIP of CSVs, an Excel workbook, a folder of PDFs. They don't know or care about our schema. They just know they need to get their data into the system.&lt;/p&gt;

&lt;p&gt;Behind the scenes: the system unpacks the archive and identifies file formats. An AI agent examines samples from each file, detects what each column likely represents, and generates a transformation config. The system runs that config, quality gates verify the output, and if something fails, the agent adjusts and retries. If it still can't resolve the issue, a human gets a specific report: "45% of addresses didn't geocode. Likely cause: PO boxes mixed with street addresses. 1,247 rows affected."&lt;/p&gt;

&lt;p&gt;The user who uploaded never sees any of this. They get "success" or "we need to clarify a few things."&lt;/p&gt;

&lt;p&gt;The key difference from the chat approach: an AI agent uses tools. It doesn't try to hold a million rows in its context window. It calls &lt;code&gt;pandas&lt;/code&gt; to read files, runs SQL to check distributions, executes validation functions, and writes configs. It iterates - try, check, adjust, retry - without a human in the loop for each step.&lt;/p&gt;

&lt;p&gt;An agent processing 1,000 files doesn't pipe each file through the LLM. It categorizes files by format and problem type, creates a plan per category, and executes plans with traditional tools. The LLM might make 20 API calls total for 1,000 files. The rest is &lt;code&gt;pandas&lt;/code&gt;, &lt;code&gt;Pydantic&lt;/code&gt;, and SQL doing what they do best: processing data fast and deterministically.&lt;/p&gt;

&lt;p&gt;This isn't a chatbot. It doesn't need a conversation UI. It doesn't need your attention. It reads messy files, figures out what's wrong, writes the fix, tests it, and leaves a report. When it can't fix something, it tells you exactly what's wrong and why. That's the kind of AI I'm excited about - not the one that talks to you, but the one that works for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Realities
&lt;/h2&gt;

&lt;p&gt;Let me be honest about the parts that aren't elegant.&lt;/p&gt;

&lt;p&gt;Sending every row through an LLM doesn't survive contact with the finance team. One million CSV rows at 500 tokens each through a budget API ($0.10/M tokens) costs $50 per run. Through GPT-4, it's closer to $5,000. Run that weekly across 50 data sources and you're looking at a six-figure annual line item for data cleaning alone. The config-generation approach costs a few dollars per data source (one-time LLM analysis of samples) plus pennies for &lt;code&gt;pandas&lt;/code&gt; execution on every run. That's 3-4 orders of magnitude cheaper. One practical tip: if you are sending data to an LLM, send it as CSV, not JSON. CSV uses 50-56% fewer tokens for the same data and LLMs parse it just as accurately.&lt;/p&gt;

&lt;p&gt;Rate limits compound the cost problem. We've had API providers throttle us during burst processing. Fifty thousand records hitting an LLM endpoint floods the rate limit within minutes. Batch APIs help (50% discount, higher throughput), but the latency goes from seconds to hours. If you need data cleaned before a morning deadline, that's a problem.&lt;/p&gt;

&lt;p&gt;LLMs also aren't deterministic. Same prompt, different run, slightly different output. For &lt;em&gt;planning&lt;/em&gt; this is fine - the generated config is deterministic once it exists. For &lt;em&gt;row-level processing&lt;/em&gt; it's a deal-breaker. I've seen the same name parsed as "John Smith Jr" in one call and "Smith, John Junior" in the next. Configs don't have this problem. They run the same way every time.&lt;/p&gt;

&lt;p&gt;And there are things AI simply can't do yet. Deduplication across records is still hard - a &lt;a href="https://arxiv.org/html/2511.21708" rel="noopener noreferrer"&gt;2025 evaluation&lt;/a&gt; found that LLMs excel at standardization and profiling but fail at non-exact deduplication. "J. Smith at 123 Main" vs "John Smith at 123 Main St Apt 4" requires comparing millions of pairs, and LLMs are both too slow and too inconsistent for this. Use &lt;code&gt;dedupe&lt;/code&gt; or &lt;code&gt;recordlinkage&lt;/code&gt; for fuzzy matching. AI can help &lt;em&gt;define&lt;/em&gt; the matching rules, but the execution needs traditional algorithms.&lt;/p&gt;

&lt;p&gt;For high-volume or privacy-sensitive pipelines, self-hosting (Ollama for development, vLLM for production, cloud GPUs for burst) eliminates rate limits and API costs. The break-even is roughly 2 million tokens per day at 70%+ utilization. Below that, APIs are cheaper when you factor in infrastructure overhead.&lt;/p&gt;

&lt;p&gt;The upside that makes all of this worthwhile is resilience. Traditional pipelines are brittle - one unexpected column name, one new encoding, one schema change, and the pipeline crashes at 3 AM. AI-assisted pipelines degrade gracefully. New format arrives? The agent analyzes the sample and generates a new config. Encoding changed? Detected and handled. Unseen edge case? Flagged for review instead of silently producing garbage. Each new data source makes the system smarter, not more fragile. The library of configs grows, the quality gate thresholds get refined, and the percentage of cases handled automatically increases over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent Helper
&lt;/h2&gt;

&lt;p&gt;Everyone is tired of chatbots. "Talk to AI" has become the default pitch for every product, every feature, every startup. But there's another kind of AI that doesn't get the hype and does most of the useful work.&lt;/p&gt;

&lt;p&gt;It doesn't have a chat interface. It doesn't need your attention. It reads 500 messy files while you sleep, figures out what's wrong with each one, writes the transformation config, tests it against quality gates, and leaves a report on your desk in the morning. When it can't fix something, it tells you exactly what's wrong and why. When a new format arrives that it's never seen before, it adapts instead of crashing.&lt;/p&gt;

&lt;p&gt;What previously required a week of an onboarding engineer's time - staring at spreadsheets, guessing at column meanings, writing one-off scripts, debugging encoding issues - now takes an afternoon of supervised automation. The human becomes the reviewer, not the laborer. That's a better use of judgment.&lt;/p&gt;

&lt;p&gt;The three-year lesson is simple. AI is best as a planner and edge-case resolver, not as a row-level processor. Define your target schema. Sample the data. Let AI generate the transformation plan. Verify with quality gates. Execute at scale with traditional tools. Handle exceptions. That's the pattern, and it works.&lt;/p&gt;

&lt;p&gt;This approach isn't revolutionary - the industry has converged on it independently. Every major data platform shipped some version of "AI suggests, human approves" in 2025. What's still missing is the practitioner knowledge: what thresholds to set, how to structure the feedback loop, when to route to AI versus when traditional tools are sufficient. That's what I tried to share here.&lt;/p&gt;

&lt;p&gt;As agents get better at using tools, this pattern will become standard infrastructure. Today it's an engineering pattern you build. Soon it'll be a checkbox in your data platform. But the fundamentals - target schemas, quality gates, confidence-based routing - those don't change. They're the boring, essential foundation that makes the AI part trustworthy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article was created with the assistance of Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>data</category>
      <category>python</category>
      <category>production</category>
    </item>
  </channel>
</rss>
