<?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: Jeffery Kachukwucide</title>
    <description>The latest articles on DEV Community by Jeffery Kachukwucide (@jeffery_kachukwucide_d41c).</description>
    <link>https://dev.to/jeffery_kachukwucide_d41c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3900165%2F656d3f58-7636-4a92-aecd-e80421ade95a.png</url>
      <title>DEV Community: Jeffery Kachukwucide</title>
      <link>https://dev.to/jeffery_kachukwucide_d41c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeffery_kachukwucide_d41c"/>
    <language>en</language>
    <item>
      <title>Building a Rate-Limited API with Upstash Redis and Python</title>
      <dc:creator>Jeffery Kachukwucide</dc:creator>
      <pubDate>Tue, 07 Jul 2026 01:29:51 +0000</pubDate>
      <link>https://dev.to/jeffery_kachukwucide_d41c/building-a-rate-limited-api-with-upstash-redis-and-python-59ld</link>
      <guid>https://dev.to/jeffery_kachukwucide_d41c/building-a-rate-limited-api-with-upstash-redis-and-python-59ld</guid>
      <description>&lt;p&gt;An API with no rate limit is an API waiting to be hammered. A careless script stuck in a retry loop, a scraper with no backoff, or a client with a bug in its polling logic can all send hundreds of requests a second — and without a limit in place, your server tries to handle every single one. Rate limiting fixes this: it caps how many requests a client can make in a given window of time, and rejects anything past that cap before it reaches your application logic.&lt;/p&gt;

&lt;p&gt;This tutorial adds rate limiting to a small Flask API using Upstash, a managed Redis service with a generous free tier. By the end, every route in the API will enforce a request limit per client, and clients who go over it will get a clear &lt;code&gt;429 Too Many Requests&lt;/code&gt; response instead of silently overwhelming your server.&lt;/p&gt;

&lt;p&gt;The mental model you'll walk away with: you don't have to let bad actors or careless clients hammer your API into the ground. A Redis counter and a few lines of Python are enough to enforce a limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need before starting
&lt;/h2&gt;

&lt;p&gt;Before you start, gather the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Basic Python knowledge.&lt;/li&gt;
&lt;li&gt;Flask basics (routes, responses).&lt;/li&gt;
&lt;li&gt;An Upstash account (free tier).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;redis-py&lt;/code&gt; installed (&lt;code&gt;pip install redis&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flask&lt;/code&gt; installed (&lt;code&gt;pip install flask&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;

&lt;p&gt;The API manages a simple &lt;code&gt;items&lt;/code&gt; resource with standard CRUD routes: &lt;code&gt;GET /items&lt;/code&gt;, &lt;code&gt;GET /items/&amp;lt;id&amp;gt;&lt;/code&gt;, &lt;code&gt;POST /items&lt;/code&gt;, &lt;code&gt;PUT /items/&amp;lt;id&amp;gt;&lt;/code&gt;, and &lt;code&gt;DELETE /items/&amp;lt;id&amp;gt;&lt;/code&gt;. Items live in memory — this tutorial is about protecting the API, not persisting data, so storage stays as simple as possible.&lt;/p&gt;

&lt;p&gt;The interesting part is a single decorator, &lt;code&gt;@rate_limit&lt;/code&gt;, that sits on every route. It uses Redis to track how many requests each client has made in the current time window and rejects any request past the configured limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create an Upstash Redis database
&lt;/h2&gt;

&lt;p&gt;Upstash gives you a Redis database with a REST-friendly free tier, and no local Redis installation to manage.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://console.upstash.com" rel="noopener noreferrer"&gt;console.upstash.com&lt;/a&gt; and sign up or log in.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create Database&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Give it a name, pick a region close to you, and select the &lt;strong&gt;Free&lt;/strong&gt; plan.&lt;/li&gt;
&lt;li&gt;Open the new database and go to the &lt;strong&gt;Details&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Copy the &lt;strong&gt;Redis URL&lt;/strong&gt;. It starts with &lt;code&gt;rediss://&lt;/code&gt; and already bundles your username, password, host, and port into one string.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Save that URL somewhere for the next step — it's the only Upstash-specific detail this project needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect Python to Upstash Redis
&lt;/h2&gt;

&lt;p&gt;Upstash Redis speaks the standard Redis protocol, so the regular &lt;code&gt;redis-py&lt;/code&gt; client connects to it directly. No Upstash-specific SDK is required.&lt;/p&gt;

&lt;p&gt;Start by centralizing configuration in one module, so every environment variable the app depends on is visible in one place:&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;# config.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;REDIS_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;UPSTASH_REDIS_URL&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;RATE_LIMIT_MAX_REQUESTS&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;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="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;RATE_LIMIT_MAX_REQUESTS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;RATE_LIMIT_WINDOW_SECONDS&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;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="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;RATE_LIMIT_WINDOW_SECONDS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;))&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;REDIS_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPSTASH_REDIS_URL is not set. Copy .env.example to .env, fill in &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your Upstash connection string, then export the variables before &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;running the app.&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;&lt;code&gt;RATE_LIMIT_MAX_REQUESTS&lt;/code&gt; and &lt;code&gt;RATE_LIMIT_WINDOW_SECONDS&lt;/code&gt; control the limit itself — the defaults above allow 10 requests per 60-second window. Raising the &lt;code&gt;RuntimeError&lt;/code&gt; early means a missing connection string fails loudly at startup instead of causing confusing errors later.&lt;/p&gt;

&lt;p&gt;With configuration in place, create the Redis client:&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;# redis_client.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;REDIS_URL&lt;/span&gt;

&lt;span class="n"&gt;redis_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REDIS_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode_responses&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redis.from_url&lt;/code&gt; accepts the &lt;code&gt;rediss://&lt;/code&gt; URL exactly as Upstash provides it.&lt;/strong&gt; The extra "s" means the connection is TLS-encrypted, and &lt;code&gt;redis-py&lt;/code&gt; handles that automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;decode_responses=True&lt;/code&gt; tells the client to return plain Python strings instead of bytes.&lt;/strong&gt; This keeps the rate-limiting code below simpler to read.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Build the rate limiter
&lt;/h2&gt;

&lt;p&gt;This is the core of the tutorial: a fixed-window counter that tracks requests per client using Redis.&lt;/p&gt;

&lt;p&gt;The algorithm has four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build a Redis key unique to both the client and the current time window — for example, &lt;code&gt;ratelimit:203.0.113.5:28819023&lt;/code&gt;, where the number is the current Unix timestamp divided by the window length.&lt;/li&gt;
&lt;li&gt;Increment that key with &lt;code&gt;INCR&lt;/code&gt;. Redis creates missing keys at 0 before incrementing, and &lt;code&gt;INCR&lt;/code&gt; is atomic, so concurrent requests from the same client can never race each other into an incorrect count.&lt;/li&gt;
&lt;li&gt;On the first request in a new window, set an &lt;code&gt;EXPIRE&lt;/code&gt; equal to the window length. Redis deletes the key on its own once the window ends, so the counter resets with no cleanup code required.&lt;/li&gt;
&lt;li&gt;Once the count passes the configured limit, reject every further request in that window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the implementation:&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;# rate_limiter.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;functools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;wraps&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RATE_LIMIT_MAX_REQUESTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RATE_LIMIT_WINDOW_SECONDS&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_client_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Identify the caller by IP address.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remote_addr&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_rate_limited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Increment and check a client&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s request count for the current window.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;current_window&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="n"&gt;RATE_LIMIT_WINDOW_SECONDS&lt;/span&gt;
    &lt;span class="n"&gt;redis_key&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;ratelimit:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="si"&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;current_window&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# This single INCR call is the entire rate-limit counter.
&lt;/span&gt;    &lt;span class="n"&gt;request_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis_key&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;request_count&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="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RATE_LIMIT_WINDOW_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;seconds_into_window&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="n"&gt;RATE_LIMIT_WINDOW_SECONDS&lt;/span&gt;
    &lt;span class="n"&gt;seconds_remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RATE_LIMIT_WINDOW_SECONDS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;seconds_into_window&lt;/span&gt;

    &lt;span class="n"&gt;info&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;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RATE_LIMIT_MAX_REQUESTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;max&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="n"&gt;RATE_LIMIT_MAX_REQUESTS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;request_count&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_after&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seconds_remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;request_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RATE_LIMIT_MAX_REQUESTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's absent: no locks, no background cleanup job, no scheduled task to purge old counters. &lt;code&gt;INCR&lt;/code&gt; and &lt;code&gt;EXPIRE&lt;/code&gt; push both the concurrency safety and the cleanup work onto Redis, which is exactly what it's designed for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;_get_client_id&lt;/code&gt; identifies callers by IP address, which is enough for a tutorial and for many small APIs. If this app sat behind a reverse proxy or load balancer, you'd read the client's real IP from the &lt;code&gt;X-Forwarded-For&lt;/code&gt; header instead — otherwise every request would appear to come from the proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply the rate limiter to your routes
&lt;/h2&gt;

&lt;p&gt;A rate limiter that nothing calls doesn't protect anything. The connection point is a decorator that wraps &lt;code&gt;is_rate_limited&lt;/code&gt; around each route:&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;# rate_limiter.py (continued)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nd"&gt;@wraps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;client_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_client_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;limited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;is_rate_limited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&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;limited&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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;rate_limit_exceeded&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;message&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Too many requests. Limit is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;limit&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="s"&gt; requests &lt;/span&gt;&lt;span class="sh"&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;per &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RATE_LIMIT_WINDOW_SECONDS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; seconds.&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;retry_after_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_after&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_after&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;f&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applying it to a route is a one-line addition:&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;# app.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rate_limiter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rate_limit&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&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;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nd"&gt;@rate_limit&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_items_route&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;jsonify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;@rate_limit&lt;/code&gt; decorator sits above every other route in &lt;code&gt;app.py&lt;/code&gt; — the POST, PUT, DELETE, and single-item GET all get it too. Because the decorator runs before the view function's own code, a rejected request never touches &lt;code&gt;store.py&lt;/code&gt; at all; it's turned away at the door. This is also why the CRUD logic in &lt;code&gt;store.py&lt;/code&gt; (an in-memory dict guarded by a thread lock) doesn't need to know anything about rate limiting, HTTP, or Redis — it just manages items, and the decorator handles abuse protection as a separate concern layered on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Return a proper error response when the limit is exceeded
&lt;/h2&gt;

&lt;p&gt;A rate limiter is only useful if callers can tell &lt;em&gt;why&lt;/em&gt; their request failed and &lt;em&gt;when&lt;/em&gt; to try again. The &lt;code&gt;rate_limit&lt;/code&gt; decorator above returns two things that matter for that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;429&lt;/code&gt; status code is the standard HTTP status for "you've sent too many requests."&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;Retry-After&lt;/code&gt; header tells well-behaved clients exactly how many seconds to wait.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The JSON body follows the same idea, giving both a machine-readable error code and a human-readable message:&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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rate_limit_exceeded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Too many requests. Limit is 10 requests per 60 seconds."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retry_after_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;37&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;A client library can check &lt;code&gt;response.status_code == 429&lt;/code&gt;, read &lt;code&gt;Retry-After&lt;/code&gt;, and back off automatically — no guessing required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the rate limiter locally
&lt;/h2&gt;

&lt;p&gt;Copy &lt;code&gt;.env.example&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt;, paste in your Upstash Redis URL, and export the variables into your shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; .env | xargs&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install dependencies and start the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the default limit of 10 requests per 60 seconds, send 11 requests in a row and watch the last one fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 11&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://localhost:5000/items
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first 10 lines print &lt;code&gt;200&lt;/code&gt;. The 11th prints &lt;code&gt;429&lt;/code&gt; — the rate limiter did its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know the fixed-window trade-off
&lt;/h2&gt;

&lt;p&gt;Everything above adds up to a small amount of code: one Redis client, one counter function, and one decorator. In exchange, the decorator protects every route in the API from being overwhelmed by a single client, with no extra infrastructure beyond Redis itself.&lt;/p&gt;

&lt;p&gt;This fixed-window approach is deliberately simple, and simple has a real trade-off worth knowing: a client can send its full limit right at the end of one window and its full limit again right at the start of the next, effectively doubling the rate for a brief moment at window boundaries. For most APIs, that's a fine trade for the simplicity of &lt;code&gt;INCR&lt;/code&gt; plus &lt;code&gt;EXPIRE&lt;/code&gt;. If you outgrow this fixed-window approach, the same Redis connection can support a sliding-window or token-bucket algorithm instead — but the core idea doesn't change: a Redis counter and a few lines of Python are enough to keep bad actors and careless clients from taking your API down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Repository link : &lt;a href="https://github.com/anthonyonyenaobijeffery-debug/Protecting-Your-Python-API-from-Abuse-with-Upstash-Redis" rel="noopener noreferrer"&gt;https://github.com/anthonyonyenaobijeffery-debug/Protecting-Your-Python-API-from-Abuse-with-Upstash-Redis&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Transactional Email CLI with Mailgun and Python</title>
      <dc:creator>Jeffery Kachukwucide</dc:creator>
      <pubDate>Sun, 05 Jul 2026 23:13:36 +0000</pubDate>
      <link>https://dev.to/jeffery_kachukwucide_d41c/building-a-transactional-email-cli-with-mailgun-and-python-36ia</link>
      <guid>https://dev.to/jeffery_kachukwucide_d41c/building-a-transactional-email-cli-with-mailgun-and-python-36ia</guid>
      <description>&lt;p&gt;If your app needs to send a welcome email, a password reset link, or an order confirmation, you don't need to stand up a mail server. You need one HTTP call.&lt;/p&gt;

&lt;p&gt;This tutorial builds a small Python CLI that sends transactional email through Mailgun's API, then checks whether Mailgun actually delivered it. By the end, you'll have replaced "sending email" with a simpler model: call an API, read the response, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need before starting
&lt;/h2&gt;

&lt;p&gt;Before you start, gather the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Basic Python knowledge.&lt;/li&gt;
&lt;li&gt;Python installed.&lt;/li&gt;
&lt;li&gt;A Mailgun account (the free tier works).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;requests&lt;/code&gt; library (&lt;code&gt;pip install requests&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;

&lt;p&gt;The app is a small CRUD tool with four actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send an email.&lt;/li&gt;
&lt;li&gt;List what's been sent.&lt;/li&gt;
&lt;li&gt;Check delivery status.&lt;/li&gt;
&lt;li&gt;Delete a record.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two files do the real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mailgun_client.py&lt;/code&gt; — the only file that talks to Mailgun; it sends emails and checks their status.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;storage.py&lt;/code&gt; — logs each sent email to a local JSON file, so there's something to check later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; wires these two together into a menu, and &lt;code&gt;config.py&lt;/code&gt; loads your Mailgun credentials. Neither file does anything Mailgun-specific. This tutorial moves through them quickly and spends most of its time on &lt;code&gt;mailgun_client.py&lt;/code&gt;, where the real subject lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up your Mailgun account
&lt;/h2&gt;

&lt;p&gt;Sign up for a free Mailgun account. Every account starts with a sandbox domain — a Mailgun-provided domain you can send test emails from immediately, without verifying your own domain's DNS records.&lt;/p&gt;

&lt;p&gt;Grab two things from the dashboard, both under &lt;strong&gt;Sending → Domain settings&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your &lt;strong&gt;domain&lt;/strong&gt; (the sandbox domain, e.g. &lt;code&gt;sandboxXXXX.mailgun.org&lt;/code&gt;, or your own verified domain)&lt;/li&gt;
&lt;li&gt;Your &lt;strong&gt;API key&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One sandbox-specific rule catches people the first time: sandbox domains only deliver to &lt;em&gt;authorized recipients&lt;/em&gt;. Before you can send yourself a test email, add your own address under &lt;strong&gt;Authorized Recipients&lt;/strong&gt; in the dashboard. Skip this step, and Mailgun accepts the request but never delivers it — exactly the silent failure the status-check step later in this tutorial is built to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure your credentials
&lt;/h2&gt;

&lt;p&gt;Store your API key, domain, and sender address as environment variables — never in code. &lt;code&gt;config.py&lt;/code&gt; loads them with a small dotenv reader (no extra dependencies), then fails fast if anything's missing:&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="n"&gt;MAILGUN_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;MAILGUN_API_KEY&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_DOMAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;MAILGUN_DOMAIN&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_SENDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;MAILGUN_SENDER&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_BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;MAILGUN_BASE_URL&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.mailgun.net/v3&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;&lt;code&gt;MAILGUN_BASE_URL&lt;/code&gt; defaults to Mailgun's US host. If your account is on Mailgun's EU region, set this to &lt;code&gt;https://api.eu.mailgun.net/v3&lt;/code&gt; instead — worth knowing before a mismatched region costs you a confusing 401.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;MAILGUN_API_KEY&lt;/code&gt;, &lt;code&gt;MAILGUN_DOMAIN&lt;/code&gt;, and &lt;code&gt;MAILGUN_SENDER&lt;/code&gt; set in a &lt;code&gt;.env&lt;/code&gt; file, the app is ready to talk to Mailgun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send the email
&lt;/h2&gt;

&lt;p&gt;This function delivers on the tutorial's promise. Everything before it was setup; everything after it is bookkeeping.&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;send_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;):&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;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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MAILGUN_BASE_URL&lt;/span&gt;&lt;span class="si"&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;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;sender&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="n"&gt;to&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="n"&gt;subject&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A transactional email, structurally, is four fields: &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;to&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt;. Mailgun's Messages API takes exactly those four as form fields on a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/{domain}/messages&lt;/code&gt;. There's no envelope object, no MIME construction, no SMTP handshake — you build a dictionary and make one request.&lt;/p&gt;

&lt;p&gt;Two details here are easy to get wrong the first time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth is HTTP Basic Auth, not a bearer token.&lt;/strong&gt; The username is the literal string &lt;code&gt;"api"&lt;/code&gt;; your API key is the password. &lt;code&gt;requests&lt;/code&gt; handles the encoding through the &lt;code&gt;auth=&lt;/code&gt; tuple — but if you're used to APIs that expect &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;, this is a different shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The payload is form-encoded, not JSON.&lt;/strong&gt; &lt;code&gt;data={...}&lt;/code&gt; sends &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;, which is what Mailgun's Messages API expects — even though the &lt;em&gt;response&lt;/em&gt; comes back as JSON. Passing &lt;code&gt;json=&lt;/code&gt; here instead of &lt;code&gt;data=&lt;/code&gt; is a common first mistake, and the resulting error doesn't make the cause obvious.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;response.raise_for_status()&lt;/code&gt; turns a 4xx or 5xx response into a Python exception immediately, instead of letting a failed send look successful. &lt;code&gt;response.json()&lt;/code&gt; hands back Mailgun's confirmation, including a message ID — the thread that connects sending an email to checking on its delivery later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm the email actually delivered
&lt;/h2&gt;

&lt;p&gt;Here's the detail that matters most: &lt;strong&gt;Mailgun accepting your request is not the same as Mailgun delivering your email.&lt;/strong&gt; A &lt;code&gt;200&lt;/code&gt; response means "I've queued this to send," not "your recipient has it." Confirming delivery means asking Mailgun a second, separate question.&lt;/p&gt;

&lt;p&gt;Mailgun doesn't expose a "get status by message ID" endpoint. Instead, you query its Events API — a log of everything that's happened to your domain's messages — and filter by ID:&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;get_delivery_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;clean_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message_id&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;&amp;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;response&lt;/span&gt; &lt;span class="o"&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;get&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MAILGUN_BASE_URL&lt;/span&gt;&lt;span class="si"&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;MAILGUN_DOMAIN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/events&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;params&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;message-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clean_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="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;items&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;events&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="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;event&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;unknown&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;Two quirks are worth knowing before you hit them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The message ID &lt;code&gt;send_email()&lt;/code&gt; returns is wrapped in angle brackets (&lt;code&gt;&amp;lt;abc123@sandbox...mailgun.org&amp;gt;&lt;/code&gt;), but the Events filter expects the bare ID. &lt;code&gt;strip("&amp;lt;&amp;gt;")&lt;/code&gt; handles the mismatch.&lt;/li&gt;
&lt;li&gt;The event log can lag a few seconds behind the send. Check status immediately after sending, and you may get &lt;code&gt;"unknown"&lt;/code&gt; back — not because anything failed, but because Mailgun hasn't logged the event yet. Waiting a moment and checking again usually resolves it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;events[0]&lt;/code&gt; is the most recent event for that message — typically &lt;code&gt;delivered&lt;/code&gt;, &lt;code&gt;accepted&lt;/code&gt;, or &lt;code&gt;failed&lt;/code&gt;. That single string is confirmation that the email didn't just leave your app; it actually arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire the send and status functions into the app
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; calls these two functions and logs the result. The send step looks like this:&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mailgun_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAILGUN_SENDER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_record&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="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mailgun_message_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&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;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;queued&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;storage.py&lt;/code&gt; writes that record to a local JSON file — not because Mailgun requires it, but because Mailgun doesn't keep "your" list of sent emails, only a raw event log. The local record is what lets you look up a message ID later and ask &lt;code&gt;get_delivery_status()&lt;/code&gt; about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the app
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python main.py
&lt;span class="go"&gt;--- Mailgun Transactional Email Manager ---
1. Send a new email (Create)
2. List logged emails (Read)
3. Refresh delivery status (Update)
4. Delete a logged email (Delete)
5. Quit
Choose an option: 1
Recipient email: you@example.com
Subject: Test send
Body text: Hello from the Mailgun API.

&lt;/span&gt;&lt;span class="gp"&gt;Sent. Local record #&lt;/span&gt;1 created. Mailgun says: Queued. Thank you.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose option 3 and enter &lt;code&gt;1&lt;/code&gt;, and the app queries the Events API and updates the record — &lt;code&gt;queued&lt;/code&gt; becomes &lt;code&gt;delivered&lt;/code&gt; (or &lt;code&gt;accepted&lt;/code&gt;, depending on timing). If it still shows &lt;code&gt;unknown&lt;/code&gt;, wait a few seconds and check again; that's the event-log lag, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat sending email as an API call
&lt;/h2&gt;

&lt;p&gt;Nowhere in this project is there SMTP configuration, a mail server, or a queuing system to install. There's one &lt;code&gt;POST&lt;/code&gt; to send an email and one &lt;code&gt;GET&lt;/code&gt; to confirm it arrived — both plain HTTP calls made with &lt;code&gt;requests&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the mental model worth keeping: sending email from an app isn't a mail-server problem. It's an API call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The repository for the code link : &lt;a href="https://github.com/anthonyonyenaobijeffery-debug/Sending-Transactional-Emails-in-Python-with-Mailgun" rel="noopener noreferrer"&gt;https://github.com/anthonyonyenaobijeffery-debug/Sending-Transactional-Emails-in-Python-with-Mailgun&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>cli</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building an Event-Driven User Signup Flow with Redpanda and Python</title>
      <dc:creator>Jeffery Kachukwucide</dc:creator>
      <pubDate>Sat, 04 Jul 2026 22:03:02 +0000</pubDate>
      <link>https://dev.to/jeffery_kachukwucide_d41c/building-an-event-driven-user-signup-flow-with-redpanda-and-python-1joh</link>
      <guid>https://dev.to/jeffery_kachukwucide_d41c/building-an-event-driven-user-signup-flow-with-redpanda-and-python-1joh</guid>
      <description>&lt;p&gt;This tutorial builds an event-driven signup flow with Python and Redpanda: creating, updating, and deleting a user become events on a Redpanda Cloud topic instead of direct database writes. You'll write a producer that turns CLI commands into events, a consumer that folds those events into a SQLite table, and a query script that reads that table directly — never touching Redpanda at all. That last piece is where the event-driven mental model actually clicks into place.&lt;/p&gt;

&lt;p&gt;In a typical signup flow, one function does everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validates the input.&lt;/li&gt;
&lt;li&gt;Writes a row to the database.&lt;/li&gt;
&lt;li&gt;Maybe queues a welcome email.&lt;/li&gt;
&lt;li&gt;Maybe updates an analytics table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every time the app needs a new reaction to a signup, someone edits that same function. The database write and the side effects it triggers stay welded together.&lt;/p&gt;

&lt;p&gt;Event-driven design breaks that weld. Instead of reacting to a signup inline, the signup handler publishes one fact: "this happened." Anything downstream — a database writer, an email service, an analytics job — subscribes to that fact on its own schedule, in its own process, without knowing the handler exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need before starting
&lt;/h2&gt;

&lt;p&gt;This tutorial assumes Python — reading a CLI script and a SQLite call should feel familiar — but not event streaming. If you've never used a message broker before, that's the gap this tutorial fills:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What an event is.&lt;/li&gt;
&lt;li&gt;Why a broker sits between the code that causes an event and the code that reacts to it.&lt;/li&gt;
&lt;li&gt;How that separation looks in real Python.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick note on terms: Redpanda's client library calls the object traveling through a topic a message. This tutorial calls that same object an event once its payload holds a &lt;code&gt;UserEvent&lt;/code&gt;. The two words describe the same thing — message is the broker's vocabulary, event is this tutorial's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a Redpanda Cloud cluster
&lt;/h2&gt;

&lt;p&gt;Redpanda Cloud's Serverless tier removes the infrastructure step entirely — no Docker, no cluster sizing, no networking to configure.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign up for Redpanda Cloud and create a Serverless cluster. It's provisioned in a few minutes and ready to use immediately.&lt;/li&gt;
&lt;li&gt;On the Topics page, create a topic named &lt;code&gt;user-lifecycle&lt;/code&gt; — this is where every signup, update, and delete event will land.&lt;/li&gt;
&lt;li&gt;On the Security page, create a SASL user with the SCRAM-SHA-256 mechanism, and save its password somewhere safe.&lt;/li&gt;
&lt;li&gt;On the cluster's Overview page, copy the bootstrap server address. Every producer and consumer you write will connect through this one address.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You now have four values: the bootstrap server, a username, a password, and a mechanism name. Everything else in this tutorial builds on those four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure the project
&lt;/h2&gt;

&lt;p&gt;Install the single third-party dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requirements.txt&lt;/code&gt; lists exactly one package: &lt;code&gt;kafka-python&lt;/code&gt;. It speaks Kafka's wire protocol, which Redpanda implements, so the same client library that talks to Kafka talks to Redpanda without modification.&lt;/p&gt;

&lt;p&gt;Copy &lt;code&gt;.env.example&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt; and fill in the four values from your cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;REDPANDA_BOOTSTRAP_SERVERS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;bootstrap-server-address&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;REDPANDA_SASL_USERNAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;your-username&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;REDPANDA_SASL_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;your-password&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;REDPANDA_SASL_MECHANISM&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;SCRAM-SHA-256&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config.py&lt;/code&gt; is the only file that reads these variables. It loads them once and hands every other module a ready-made connection dict:&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;kafka_common_config&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Connection kwargs shared by both KafkaProducer and KafkaConsumer.&lt;/span&gt;&lt;span class="sh"&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;bootstrap_servers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BOOTSTRAP_SERVERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;security_protocol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SECURITY_PROTOCOL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sasl_mechanism&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SASL_MECHANISM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sasl_plain_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SASL_USERNAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sasl_plain_password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SASL_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Producer and consumer both call this function, so the connection settings live in exactly one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the event contract
&lt;/h2&gt;

&lt;p&gt;Before writing a producer or a consumer, define the shape of the event they'll exchange. &lt;code&gt;models.py&lt;/code&gt; holds the &lt;code&gt;UserEvent&lt;/code&gt; dataclass, and it imports neither &lt;code&gt;kafka&lt;/code&gt; nor &lt;code&gt;sqlite3&lt;/code&gt; — the event schema is a plain contract, independent of how it's transported or stored.&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="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;event_type&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;user_id&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;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;
    &lt;span class="n"&gt;event_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="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="n"&gt;timestamp&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="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;to_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;asdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@staticmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;from_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UserEvent&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&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="nc"&gt;UserEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;event_type&lt;/code&gt; is one of &lt;code&gt;SIGNUP&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, or &lt;code&gt;DELETE&lt;/code&gt;. &lt;code&gt;payload&lt;/code&gt; carries whatever data that event type needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An email and name for a signup.&lt;/li&gt;
&lt;li&gt;A status change for an update.&lt;/li&gt;
&lt;li&gt;Nothing at all for a delete.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping the envelope generic like this means adding a new event type later costs a new constant and a new branch in the consumer's dispatch — not a new topic or a schema migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the producer: turn a CLI command into an event
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;producer.py&lt;/code&gt; is the only place a "signup" becomes an event. It builds a &lt;code&gt;KafkaProducer&lt;/code&gt;, then serializes and sends a &lt;code&gt;UserEvent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_producer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;KafkaProducer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;KafkaProducer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kafka_common_config&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;key_serializer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&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;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;value_serializer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;acks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KafkaProducer&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;UserEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TOPIC_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&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="nf"&gt;to_json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;record_metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;future&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;published &lt;/span&gt;&lt;span class="si"&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;event_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; for &lt;/span&gt;&lt;span class="si"&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;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&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;-&amp;gt; partition &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;record_metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, offset &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;record_metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;offset&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;Two choices in this code matter more than the rest of the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keying by &lt;code&gt;user_id&lt;/code&gt;.&lt;/strong&gt; Redpanda only guarantees message order within a single partition, and messages sharing a key always land on the same partition. Keying by &lt;code&gt;user_id&lt;/code&gt; — not by &lt;code&gt;event_id&lt;/code&gt; — keeps one user's &lt;code&gt;SIGNUP → UPDATE → DELETE&lt;/code&gt; sequence from arriving out of order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;acks="all"&lt;/code&gt;.&lt;/strong&gt; The producer waits for Redpanda's full in-sync replica set to confirm the write, not just the partition leader, before it considers the event published.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of &lt;code&gt;producer.py&lt;/code&gt; is CLI plumbing: &lt;code&gt;argparse&lt;/code&gt; subcommands for &lt;code&gt;signup&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, and &lt;code&gt;delete&lt;/code&gt; that build a &lt;code&gt;UserEvent&lt;/code&gt; from command-line flags and hand it to &lt;code&gt;publish()&lt;/code&gt;. None of it touches SQLite, and none of it knows a consumer exists. That's the point — the producer's only responsibility is turning a command into an event and putting it on the topic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the consumer: fold events into a read-model
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;consumer.py&lt;/code&gt; is the other half of the contract. It doesn't expose any CRUD API of its own — it subscribes to the topic and replays whatever the producer published, in order, into a SQLite table:&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;apply_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UserRepository&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;UserEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGNUP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&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="n"&gt;payload&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="n"&gt;name&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="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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;user_id&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;payload&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;timestamp&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DELETE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;repo&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main loop wires a &lt;code&gt;KafkaConsumer&lt;/code&gt; to this dispatch function:&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="n"&gt;consumer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KafkaConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TOPIC_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kafka_common_config&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;group_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CONSUMER_GROUP_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auto_offset_reset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;earliest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;enable_auto_commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;key_deserializer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;value_deserializer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UserEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;apply_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&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;consumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;enable_auto_commit=False&lt;/code&gt; is the detail worth pausing on. The consumer commits its offset by hand, only after &lt;code&gt;apply_event()&lt;/code&gt; finishes — after the row lands in SQLite. If the process crashes between reading a message and finishing the write, Redpanda still considers that message unread, and the consumer replays it on restart. This guarantee is called at-least-once delivery: it never loses an event, but it can redeliver one.&lt;/p&gt;

&lt;p&gt;Redelivery only stays safe because &lt;code&gt;repository.py&lt;/code&gt; writes with &lt;code&gt;INSERT OR REPLACE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        INSERT OR REPLACE INTO users
            (user_id, email, name, status, created_at, updated_at)
        VALUES (?, ?, ?, ?, ?, ?)
        &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&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&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="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replaying the same &lt;code&gt;SIGNUP&lt;/code&gt; event twice overwrites a row with identical values instead of failing on a duplicate primary key. At-least-once delivery and idempotent writes are a pair: one guarantees you won't miss an event, the other guarantees seeing it twice doesn't corrupt anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the query script: read state without touching Redpanda
&lt;/h2&gt;

&lt;p&gt;Everything so far has been the write path. &lt;code&gt;query.py&lt;/code&gt; is the read path, and it's short enough to show in full:&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;if&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="si"&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="si"&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&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;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# get
&lt;/span&gt;    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&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;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&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;no user found with id &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;kafka&lt;/code&gt; import. No producer, no consumer, no topic. &lt;code&gt;query.py&lt;/code&gt; reads straight from the &lt;code&gt;users&lt;/code&gt; table that &lt;code&gt;consumer.py&lt;/code&gt; already built. This is the mental-model shift the whole tutorial has been building toward: writes travel through events so that any number of independent consumers could react to them, but reads don't need Redpanda at all. Reads just hit the materialized view the consumer already produced.&lt;/p&gt;

&lt;p&gt;That separation is also why &lt;code&gt;producer.py&lt;/code&gt; never imports &lt;code&gt;repository.py&lt;/code&gt;, and why &lt;code&gt;repository.py&lt;/code&gt; never imports &lt;code&gt;kafka&lt;/code&gt;. The write path and the read path are two independent code paths that agree on exactly one thing — the &lt;code&gt;UserEvent&lt;/code&gt; schema in &lt;code&gt;models.py&lt;/code&gt; — and nothing else. A second consumer, like an email sender or an audit log, could subscribe to the same topic tomorrow without &lt;code&gt;producer.py&lt;/code&gt; or &lt;code&gt;query.py&lt;/code&gt; changing by a single line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the flow end-to-end
&lt;/h2&gt;

&lt;p&gt;Start the consumer first, in its own terminal, so it's listening before any events arrive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python consumer.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a second terminal, publish a signup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python producer.py signup &lt;span class="nt"&gt;--email&lt;/span&gt; ada@example.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Ada Lovelace"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The producer's terminal prints the partition and offset the event landed on. The consumer's terminal prints confirmation that the user now exists in the SQLite table. Update and delete work the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python producer.py update &lt;span class="nt"&gt;--user-id&lt;/span&gt; &amp;lt;user_id&amp;gt; &lt;span class="nt"&gt;--status&lt;/span&gt; inactive
python producer.py delete &lt;span class="nt"&gt;--user-id&lt;/span&gt; &amp;lt;user_id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check the current state, reading straight from SQLite and not through Redpanda:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python query.py list
python query.py get &lt;span class="nt"&gt;--user-id&lt;/span&gt; &amp;lt;user_id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What you built
&lt;/h2&gt;

&lt;p&gt;The CRUD operations here are deliberately simple — a signup flow was never the hard part. What's worth taking away is the shape underneath this signup flow: a producer that only knows how to turn a command into an event, a consumer that only knows how to fold events into state, and a read path that skips Redpanda entirely, because reads don't depend on it. That shape scales past this tutorial's four commands — any process that needs to react to a user signing up subscribes to the same topic, on its own schedule, without ever touching &lt;code&gt;producer.py&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>python</category>
      <category>streaming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Conflict-Safe Notes App with Supabase and Vanilla JavaScript</title>
      <dc:creator>Jeffery Kachukwucide</dc:creator>
      <pubDate>Wed, 01 Jul 2026 22:02:22 +0000</pubDate>
      <link>https://dev.to/jeffery_kachukwucide_d41c/building-a-conflict-safe-notes-app-with-supabase-and-vanilla-javascript-53cb</link>
      <guid>https://dev.to/jeffery_kachukwucide_d41c/building-a-conflict-safe-notes-app-with-supabase-and-vanilla-javascript-53cb</guid>
      <description>&lt;p&gt;This article shows how one SQL trigger and one extra &lt;code&gt;WHERE&lt;/code&gt; clause let a vanilla JavaScript client detect and resolve edit conflicts — with no locking system, no hand-maintained version column, and no custom server.&lt;/p&gt;

&lt;p&gt;The example assumes you already know SQL and JavaScript. With that background, Supabase can feel deceptively easy to pick up: it's Postgres with a JavaScript client bolted on. That's mostly true, except for one spot where the two layers stop being separate and have to hand off responsibility to each other — what happens when two people edit the same row at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;

&lt;p&gt;A single-page notes app: a form to add notes, a list of note cards, and inline edit, save, and delete actions. Multiple browser tabs — or multiple people — can have the app open at once, and a Supabase realtime channel keeps them in sync.&lt;/p&gt;

&lt;p&gt;This article covers one slice of that app: what happens when two clients edit the same note at the same time. The realtime sync and the basic CRUD operations are standard Supabase patterns, so the article moves through them quickly to spend most of its time on the conflict-detection logic described above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up your Supabase project
&lt;/h2&gt;

&lt;p&gt;Your &lt;code&gt;schema.sql&lt;/code&gt; file does more than create a table. Supabase requires two settings that a plain, self-managed Postgres setup doesn't need, plus one more piece — a trigger — that the rest of this article depends on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enable Row Level Security
&lt;/h3&gt;

&lt;p&gt;In a typical backend, your server connects to Postgres with credentials that already have table permissions, and that's the end of it. Supabase flips this: the client ships an anon key straight into the browser, so anyone holding that key can call the API. Row Level Security (RLS) is the gate that decides what the anon key can actually do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"public full access (demo only)"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With RLS enabled and no policy, Supabase denies all access by default — the opposite of a typical open Postgres setup. The policy above allows everything, which works for learning but is wrong for anything real.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enable realtime updates
&lt;/h3&gt;

&lt;p&gt;Creating a table doesn't automatically stream its changes. You also need to add the table to Supabase's realtime publication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="n"&gt;publication&lt;/span&gt; &lt;span class="n"&gt;supabase_realtime&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_publication_tables&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;pubname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'supabase_realtime'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'notes'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip this step, and &lt;code&gt;postgres_changes&lt;/code&gt; subscriptions on &lt;code&gt;notes&lt;/code&gt; will connect successfully but never fire. This silent failure is worth knowing about before you go looking for bugs elsewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add the updated_at trigger
&lt;/h3&gt;

&lt;p&gt;The rest of this article depends on one more piece: a trigger that stamps every update with a server-generated timestamp.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;set_updated_at&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="n"&gt;trg_notes_updated_at&lt;/span&gt;
&lt;span class="k"&gt;before&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;each&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt;
&lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;set_updated_at&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every &lt;code&gt;UPDATE&lt;/code&gt; to this table, the database overwrites &lt;code&gt;updated_at&lt;/code&gt; unconditionally, before it writes the row. The client can send whatever it wants in that field — the trigger ignores it. Hold onto that fact: it's what makes the conflict detection in this article trustworthy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect the JavaScript client
&lt;/h3&gt;

&lt;p&gt;On the JavaScript side, setup is one line:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This client is your only connection to Postgres from here on. Every query in the rest of this article is a method chain on &lt;code&gt;db&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add the standard CRUD operations
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fetchNotes&lt;/code&gt;, &lt;code&gt;createNote&lt;/code&gt;, and &lt;code&gt;deleteNote&lt;/code&gt; map onto SQL about as literally as you'd expect. Here's &lt;code&gt;fetchNotes&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchNotes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ascending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createNote&lt;/code&gt; and &lt;code&gt;deleteNote&lt;/code&gt; follow the same pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.from('notes').select('*')&lt;/code&gt; is &lt;code&gt;SELECT * FROM notes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.insert({...}).select().single()&lt;/code&gt; is an &lt;code&gt;INSERT ... RETURNING&lt;/code&gt;, unwrapped from an array to a single object because exactly one row comes back.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.delete().eq('id', id).select()&lt;/code&gt; is a &lt;code&gt;DELETE ... RETURNING&lt;/code&gt;; the client uses the returned row to confirm a row actually existed to delete.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this logic is Supabase-specific — it's SQL wearing a JavaScript accent. The next section covers the one operation that needs more than a literal translation: &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detect conflicts with a compare-and-swap WHERE clause
&lt;/h2&gt;

&lt;p&gt;Here's the bug this app is designed around. Two tabs open the same note. Both load it with the same &lt;code&gt;updated_at&lt;/code&gt;. Tab B saves first. If Tab A's save just runs &lt;code&gt;UPDATE notes SET title = ..., content = ... WHERE id = X&lt;/code&gt;, it silently clobbers Tab B's change — no error, no warning, just wrong data. This is the classic "last write wins" trap, and it's invisible until two people actually collide.&lt;/p&gt;

&lt;p&gt;The fix isn't more JavaScript. It's one extra clause in the SQL:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;knownUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;knownUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&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;// null = conflict&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second &lt;code&gt;.eq()&lt;/code&gt; turns this into &lt;code&gt;UPDATE notes SET ... WHERE id = X AND updated_at = Y&lt;/code&gt;. Postgres evaluates the whole &lt;code&gt;WHERE&lt;/code&gt; clause atomically — there's no gap in time between "check if this row still matches" and "write to it" for another transaction to sneak into. Either the row's &lt;code&gt;updated_at&lt;/code&gt; still equals what this client last saw, and the write happens, or it doesn't, and Postgres touches zero rows. This is a compare-and-swap, done entirely inside a single statement, with no application-level locking at all.&lt;/p&gt;

&lt;p&gt;The JS side just has to notice which of those two outcomes happened, and &lt;code&gt;.select()&lt;/code&gt; after an &lt;code&gt;.update()&lt;/code&gt; gives it a clean signal for free: if the &lt;code&gt;WHERE&lt;/code&gt; matched nothing, Supabase returns an empty array — not an error. &lt;code&gt;data.length === 0&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the conflict.&lt;/p&gt;

&lt;p&gt;This is the seam the whole app is built around: SQL owns the atomicity, JS owns interpreting the result.&lt;/p&gt;

&lt;p&gt;The other half of the compare-and-swap lives in the trigger you added during setup. The database — not the browser — sets &lt;code&gt;updated_at&lt;/code&gt; on every write. That gives the value three guarantees the client can rely on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It can't be stale from clock skew.&lt;/li&gt;
&lt;li&gt;It can't be forged by a malicious client.&lt;/li&gt;
&lt;li&gt;It can't be forgotten or left unset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In effect, you get a free, tamper-proof version number, without adding a &lt;code&gt;version&lt;/code&gt; column or writing any bookkeeping logic yourself:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startEdit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;notesCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;editingNoteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;editingBaselineUpdatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the "version" this edit started from&lt;/span&gt;
  &lt;span class="nx"&gt;editingDraft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;startEdit()&lt;/code&gt; captures &lt;code&gt;editingBaselineUpdatedAt&lt;/code&gt; once, when editing begins. On save, &lt;code&gt;updateNote()&lt;/code&gt; sends that exact value back into its &lt;code&gt;WHERE&lt;/code&gt; clause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use realtime updates to warn, not resolve, conflicts
&lt;/h2&gt;

&lt;p&gt;You might be tempted to let the realtime subscription resolve conflicts directly: see an incoming change, merge it into the draft, done. This app deliberately doesn't — and the reason matters:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;notesCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;newRow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;!==&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="nx"&gt;notesCache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newRow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editingNoteId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;newRow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;newRow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;editingBaselineUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;showToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;newRow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Untitled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" was changed in another tab while you're editing it.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This handler never touches &lt;code&gt;editingDraft&lt;/code&gt;. It only updates the background cache and, if the edited row just changed underneath the editor, shows a toast. The actual conflict resolution happens later, when Save runs and hits the &lt;code&gt;WHERE&lt;/code&gt; clause in &lt;code&gt;updateNote()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That separation is deliberate. Websocket messages can arrive late or not at all, and a dropped connection must never cause a bad write — at worst, it should only delay or drop the warning. The app's correctness can't depend on the realtime message arriving; it depends only on the SQL compare-and-swap, which runs synchronously inside the save itself. Realtime's job is entirely about &lt;em&gt;user experience&lt;/em&gt;: it gives someone a heads-up before they hit Save, instead of a surprise after. Delete the realtime subscription entirely, and the conflict detection stays airtight — you just lose the early warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trace a two-tab conflict
&lt;/h2&gt;

&lt;p&gt;Open two tabs on the same note, side by side. Here's the sequence that follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tab A clicks Edit. The app captures &lt;code&gt;editingBaselineUpdatedAt&lt;/code&gt; as &lt;code&gt;T1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Tab B clicks Edit on the same note. Its baseline is also &lt;code&gt;T1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Tab B saves first. The clause &lt;code&gt;WHERE id = X AND updated_at = T1&lt;/code&gt; matches, the trigger bumps the row to &lt;code&gt;T2&lt;/code&gt;, and Tab B's edit lands.&lt;/li&gt;
&lt;li&gt;Realtime pushes that update to Tab A. &lt;code&gt;handleRealtimeChange&lt;/code&gt; sees that &lt;code&gt;editingNoteId&lt;/code&gt; matches and &lt;code&gt;T2 !== T1&lt;/code&gt;, so it shows the toast but leaves Tab A's draft untouched.&lt;/li&gt;
&lt;li&gt;Tab A clicks Save anyway, missing or ignoring the warning. The query runs &lt;code&gt;WHERE id = X AND updated_at = T1&lt;/code&gt;, but the row's &lt;code&gt;updated_at&lt;/code&gt; is now &lt;code&gt;T2&lt;/code&gt;. Zero rows match, and &lt;code&gt;updateNote()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;saveEdit()&lt;/code&gt; treats the &lt;code&gt;null&lt;/code&gt; as a conflict. It refetches the current notes from the server, shows a toast explaining that the save failed, and returns Tab A to read mode.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;notesCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchNotes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;showToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Save failed: this note changed elsewhere. Showing the latest version — please re-apply your edit.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;cancelEdit&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody's edit vanished silently. Tab B's write is safely on the server. The app rejected Tab A's conflicting write cleanly, explained exactly why, and gave Tab A a path to redo the edit on the current data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply this pattern beyond notes apps
&lt;/h2&gt;

&lt;p&gt;This pattern extends beyond notes apps. Whenever two clients might write the same row, don't do a "check, then write" in your application code — there's always a race window between those two steps. Push the check &lt;em&gt;into&lt;/em&gt; the write, as part of the same &lt;code&gt;WHERE&lt;/code&gt; clause, and let Postgres's transactional guarantees make it atomic for free. A database-owned timestamp — or a dedicated version column, if you want one — gives you a value to compare against without trusting the client to report it honestly.&lt;/p&gt;

&lt;p&gt;Keep realtime in its lane: it's a way to tell users something changed, not a mechanism for deciding what to do about that change. The moment a &lt;code&gt;postgres_changes&lt;/code&gt; handler starts mutating in-progress state, you've reintroduced the exact race condition the SQL layer eliminates.&lt;/p&gt;

&lt;p&gt;That's the real overlap between Supabase's JavaScript and SQL layers: not how to call &lt;code&gt;.update()&lt;/code&gt;, but where the responsibility for correctness lives — and making sure your client code never quietly takes it back.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Live Demo : &lt;a href="https://anthonyonyenaobijeffery-debug.github.io/Notes-app-with-Supabase-vanilla-JS-real-client-overlap-your-/" rel="noopener noreferrer"&gt;https://anthonyonyenaobijeffery-debug.github.io/Notes-app-with-Supabase-vanilla-JS-real-client-overlap-your-/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>javascript</category>
      <category>postgres</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Wow.

When i heard about mythos, i thought it was another upgrade , probably. but later on when i heard about the US government involvement in it , it was clear that this wasn't just an upgrade, it was a big leap.</title>
      <dc:creator>Jeffery Kachukwucide</dc:creator>
      <pubDate>Mon, 22 Jun 2026 19:42:50 +0000</pubDate>
      <link>https://dev.to/jeffery_kachukwucide_d41c/wow-when-i-heard-about-mythos-i-thought-it-was-another-upgrade-probably-but-later-on-when-i-33pm</link>
      <guid>https://dev.to/jeffery_kachukwucide_d41c/wow-when-i-heard-about-mythos-i-thought-it-was-another-upgrade-probably-but-later-on-when-i-33pm</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/georgekobaidze/ai-psychosis-is-no-longer-fiction-3258" class="crayons-story__hidden-navigation-link"&gt;AI Psychosis Is No Longer Fiction&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/georgekobaidze" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F55651%2F3ad144e9-ca91-4395-b73a-9a0a3d843af9.jpg" alt="georgekobaidze profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/georgekobaidze" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Giorgi Kobaidze
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Giorgi Kobaidze
                &lt;a href="/++"&gt;&lt;img alt="Subscriber" class="subscription-icon" src="https://assets.dev.to/assets/subscription-icon-805dfa7ac7dd660f07ed8d654877270825b07a92a03841aa99a1093bd00431b2.png"&gt;&lt;/a&gt;
              
              &lt;div id="story-author-preview-content-3956737" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/georgekobaidze" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F55651%2F3ad144e9-ca91-4395-b73a-9a0a3d843af9.jpg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Giorgi Kobaidze&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/georgekobaidze/ai-psychosis-is-no-longer-fiction-3258" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 21&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/georgekobaidze/ai-psychosis-is-no-longer-fiction-3258" id="article-link-3956737"&gt;
          AI Psychosis Is No Longer Fiction
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/claude"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;claude&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/security"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;security&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/georgekobaidze/ai-psychosis-is-no-longer-fiction-3258" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;20&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/georgekobaidze/ai-psychosis-is-no-longer-fiction-3258#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              8&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            14 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Think Before You Prompt</title>
      <dc:creator>Jeffery Kachukwucide</dc:creator>
      <pubDate>Sun, 07 Jun 2026 21:07:03 +0000</pubDate>
      <link>https://dev.to/jeffery_kachukwucide_d41c/think-before-you-prompt-56ea</link>
      <guid>https://dev.to/jeffery_kachukwucide_d41c/think-before-you-prompt-56ea</guid>
      <description>&lt;p&gt;AI Is Giving Children Answers, But Are They Learning to Think?&lt;/p&gt;

&lt;p&gt;The Importance of Critical Thinking in the Age of Artificial Intelligence&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;How I Turned 11 Weeks of AI Research Into an Interactive “Think Before You Prompt” Experience 🚀 Project Link &lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
👉 &lt;a href="https://anthonyonyenaobijeffery-debug.github.io/Think-before-you-prompt/" rel="noopener noreferrer"&gt;https://anthonyonyenaobijeffery-debug.github.io/Think-before-you-prompt/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;🧠 Introduction&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;Over the past few weeks, I explored a topic that kept bothering me:&lt;/p&gt;

&lt;p&gt;As Artificial Intelligence becomes more available to children, are they still learning how to think?&lt;/p&gt;

&lt;p&gt;AI tools today can solve homework, explain concepts, write essays, and answer questions instantly. While this is powerful, I started wondering if over-reliance on these tools could affect the development of critical thinking skills in young learners.&lt;/p&gt;

&lt;p&gt;Instead of writing a normal article, I decided to turn my research into something more engaging.&lt;/p&gt;

&lt;p&gt;I wanted to create something that feels like more than just a blog post.&lt;/p&gt;

&lt;p&gt;So I built:&lt;/p&gt;

&lt;p&gt;“Think Before You Prompt” — an interactive, hacker-style reading experience where the article writes itself on the screen.&lt;/p&gt;

&lt;p&gt;The goal was simple: Make readers experience the message instead of just reading it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;🛠️ How I Built It&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This project was built using:&lt;/p&gt;

&lt;p&gt;HTML CSS JavaScript GitHub Pages for deployment &lt;/p&gt;

&lt;p&gt;The core idea was to simulate a “terminal-style research system” that reveals information gradually using a typewriter effect.&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;Key features include:&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
Boot sequence animation Typewriter text rendering Progress bar for reading flow Terminal-style UI Live critical thinking prompts 🧩 What I Learned &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This project taught me that:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Presentation can change how people perceive an idea Simple JavaScript can create powerful storytelling effects Educational content becomes more engaging when it feels interactive Finishing projects is more important than starting them 🧠 The Message Behind the Project &lt;/p&gt;

&lt;p&gt;AI is not the problem.&lt;/p&gt;

&lt;p&gt;The real challenge is ensuring that people — especially children — continue to develop:&lt;/p&gt;

&lt;p&gt;Critical thinking Problem-solving skills Independent reasoning &lt;/p&gt;

&lt;p&gt;AI should support thinking, not replace it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;📌 Final Thoughts&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;This project started as research and ended as an experience.&lt;/p&gt;

&lt;p&gt;Instead of writing about AI and critical thinking, I built a way to feel the message through interaction.&lt;/p&gt;

&lt;p&gt;Sometimes the best way to explain an idea… is to make people experience it.&lt;br&gt;
&lt;a href="https://dev.tourl"&gt;&lt;br&gt;
🔗 Live Project &lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://anthonyonyenaobijeffery-debug.github.io/Think-before-you-prompt/" rel="noopener noreferrer"&gt;https://anthonyonyenaobijeffery-debug.github.io/Think-before-you-prompt/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>githubchallenge</category>
    </item>
    <item>
      <title>Sentient Canvas: A Localized Agentic Workspace Powered by Google's Gemma 4</title>
      <dc:creator>Jeffery Kachukwucide</dc:creator>
      <pubDate>Sun, 24 May 2026 22:37:18 +0000</pubDate>
      <link>https://dev.to/jeffery_kachukwucide_d41c/sentient-canvas-a-localized-agentic-workspace-powered-by-googles-gemma-4-1gm5</link>
      <guid>https://dev.to/jeffery_kachukwucide_d41c/sentient-canvas-a-localized-agentic-workspace-powered-by-googles-gemma-4-1gm5</guid>
      <description>&lt;p&gt;Sentient Canvas: A Localized Agentic Workspace Powered by Google's Gemma 4&lt;/p&gt;

&lt;p&gt;Welcome to the future of localized AI interactions. &lt;strong&gt;Sentient Canvas&lt;/strong&gt; is a high-performance, real-time agentic workspace built entirely on top of Google’s revolutionary &lt;strong&gt;Gemma 4&lt;/strong&gt; open-weights architecture. &lt;/p&gt;

&lt;p&gt;Traditional agent workflows are often bogged down by massive latency, fragmented multi-modal interfaces, and severe echo loops during voice interactions. Sentient Canvas solves this by organizing Gemma 4's native cognitive alignment capabilities into four discrete, hardware-accelerated "Architectural Gates," backed by an advanced, feedback-immune client-side audio pipeline.&lt;/p&gt;

&lt;p&gt;🔗 Project Links&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live Demo &amp;amp; Source Code:&lt;/strong&gt; &lt;a href="https://huggingface.co/spaces/JEFFERY0001/Gemma4.2.1" rel="noopener noreferrer"&gt;Sentient Canvas on Hugging Face Spaces&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;⚙️ How it Works: The 4 Architectural Gates&lt;br&gt;
Sentient Canvas exposes Gemma 4’s native capabilities directly via a streamlined UI, dividing complex latency-sensitive tasks into explicit operational lanes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Speed Mode (Gate A):&lt;/strong&gt; Utilizes a high-throughput processing pipeline for immediate, low-latency text responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool Connect (Gate B):&lt;/strong&gt; Implements Gemma 4's advanced function calling layer to programmatically manipulate the canvas and alter workspace layouts dynamically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vision Scan (Gate C):&lt;/strong&gt; Passes structural physical assets directly into Gemma 4’s unified multi-modal vision layers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep Think (Gate D):&lt;/strong&gt; Exposes native, token-by-token explicit reasoning streams so users can watch the system build multi-step logical constraints in real time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;🛠️ The Tech Stack &amp;amp; Overcoming Engineering Hurdles&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inference Model:&lt;/strong&gt; Google Gemma 4 (Open-Weights Cognitive Alignment)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment Hub:&lt;/strong&gt; Hugging Face Spaces&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice Protocol:&lt;/strong&gt; Web Speech API (SpeechRecognition &amp;amp; SpeechSynthesis Core)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend Real-time Sync:&lt;/strong&gt; Vanilla JavaScript &amp;amp; Tailwind CSS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Resolving the "Acoustic Feedback &amp;amp; Markdown" Problem&lt;br&gt;
During voice-to-voice testing, we ran into two critical bugs common in streaming agent interfaces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Asterisk Loop:&lt;/strong&gt; The Web Speech Synthesis engine would literally read Markdown formatting elements aloud (saying &lt;em&gt;"asterisk asterisk"&lt;/em&gt; instead of speaking normally).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Inner Monologue Leak:&lt;/strong&gt; Gemma 4's native &lt;code&gt;&amp;lt;|think|&amp;gt;&lt;/code&gt; blocks would accidentally blend into the spoken response queue, causing the AI to vocalize its internal reasoning loops.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The Solution:&lt;/strong&gt; We engineered a custom client-side pipeline filter (&lt;code&gt;sanitizeTextForSpeech&lt;/code&gt;) that intercept-checks data streams in real time. It instantly strips out markdown tokens, filters raw code strings, and blocks internal thought markers, keeping the visual text beautifully styled on the screen while routing a polished, completely clean vocal stream directly to the user.&lt;/p&gt;

&lt;p&gt;What's Next?&lt;br&gt;
Sentient Canvas proves that highly responsive, multi-modal agent loops don't need closed-source architectures or massive server infrastructure—they can run efficiently on accessible open frameworks. We plan to expand the interface to support broader tool-calling execution blocks and persistent cross-session thread storage. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built for the Google Gemma 4 challenge. Let's build the future!&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gemmachallenge</category>
      <category>devchallenge</category>
      <category>gemma</category>
    </item>
  </channel>
</rss>
