<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Lycore on Medium]]></title>
        <description><![CDATA[Stories by Lycore on Medium]]></description>
        <link>https://medium.com/@lycore?source=rss-4093c9a41614------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*TnL1iARs7802oHNN2yZJ1g.png</url>
            <title>Stories by Lycore on Medium</title>
            <link>https://medium.com/@lycore?source=rss-4093c9a41614------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 04:17:49 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@lycore/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Faster CI for Django and React: Caching, Parallel Tests and Skipping Unchanged Work]]></title>
            <link>https://medium.com/@lycore/faster-ci-for-django-and-react-caching-parallel-tests-and-skipping-unchanged-work-3b78ca5f73ca?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/3b78ca5f73ca</guid>
            <category><![CDATA[react]]></category>
            <category><![CDATA[django]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[continuous-integration]]></category>
            <category><![CDATA[github-actions]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Fri, 13 Nov 2026 13:01:03 GMT</pubDate>
            <atom:updated>2026-11-13T13:01:03.536Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_ToHVRdzQL__Ce7W-xhUbg.jpeg" /></figure><p>If your pull requests sit for fifteen minutes waiting on a green check, your team is paying for it many times a day. Engineers switch context, batch up changes to avoid the wait, and start treating CI as something to route around instead of something that protects them. Slow CI is rarely one big problem. It is usually five or six small ones that add up: dependencies downloaded from scratch on every run, a test suite running on a single core, the backend tests running when only a React component changed, Docker images rebuilt from the first layer, and nobody measuring any of it.</p><p>This post walks through the fixes we reach for first on Django and React projects that use GitHub Actions. Every example is plain YAML you can adapt. None of it needs a paid CI product.</p><h3>Start by measuring, not guessing</h3><p>Before you change anything, find out where the time goes. Open a recent workflow run and look at the step timings. In most Django and React repositories we look at, the time splits into a few buckets:</p><ul><li>Installing Python and Node dependencies</li><li>Building assets or Docker images</li><li>Running the test suites</li><li>Waiting for a runner to pick up the job</li></ul><p>Write down the median duration of your main workflow over the last few weeks. The GitHub CLI makes this easy:</p><pre>gh run list --workflow ci.yml --branch main --limit 50 \<br>  --json databaseId,conclusion,createdAt,updatedAt \<br>  &gt; runs.json</pre><p>The difference between createdAt and updatedAt gives you a rough wall-clock time per run (it includes queue time and any re-runs, so treat it as an approximation). Keep this number. Every change below should move it, and if a change does not, revert it. CI configuration has a habit of collecting clever tricks that nobody can explain a year later.</p><h3>1. Cache dependencies properly</h3><p>The cheapest win is to stop downloading the same packages on every run. The official setup-python and setup-node actions have caching built in, keyed on your lock files.</p><pre>name: ci</pre><pre>on:<br>  pull_request:<br>  push:<br>    branches: [main]</pre><pre>jobs:<br>  backend:<br>    runs-on: ubuntu-latest<br>    steps:<br>      - uses: actions/checkout@v4</pre><pre>      - uses: actions/setup-python@v5<br>        with:<br>          python-version: &quot;3.12&quot;<br>          cache: pip<br>          cache-dependency-path: requirements/*.txt</pre><pre>      - run: pip install -r requirements/dev.txt</pre><pre>  frontend:<br>    runs-on: ubuntu-latest<br>    defaults:<br>      run:<br>        working-directory: frontend<br>    steps:<br>      - uses: actions/checkout@v4</pre><pre>      - uses: actions/setup-node@v4<br>        with:<br>          node-version: 22<br>          cache: npm<br>          cache-dependency-path: frontend/package-lock.json</pre><pre>      - run: npm ci</pre><p>A few details matter here:</p><ul><li><strong>Cache keys come from lock files.</strong> If your Python dependencies are not pinned, the cache key never changes, and you will get stale or inconsistent installs. Pin with a lock file (pip-tools, uv or Poetry) so the key changes only when dependencies actually change.</li><li><strong>npm ci, not </strong><strong>npm install.</strong> npm ci installs exactly what the lock file says and fails if package.json and the lock file disagree. That is what you want in CI.</li><li><strong>The built-in cache stores the package manager’s download cache, not the installed environment.</strong> That is usually enough. If installs are still slow because of packages that compile native code, you can cache the virtual environment itself with actions/cache, keyed on the Python version and the lock file hash.</li></ul><p>GitHub documents how cache keys, restore keys and size limits work in its guide to <a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows">caching dependencies to speed up workflows</a>. It is worth reading once, mainly so you understand when caches get evicted and why a branch can read the cache from main but not the other way around.</p><h3>2. Run Django tests in parallel</h3><p>Most GitHub-hosted Linux runners have more than one CPU core, and a default pytest run uses one. If you use pytest with pytest-django, adding <a href="https://pytest-xdist.readthedocs.io/en/stable/">pytest-xdist</a> is often the single biggest speedup for the backend job.</p><pre>pip install pytest-xdist<br>pytest -n auto</pre><p>-n auto starts one worker per available CPU. pytest-django handles the database side for you: each worker gets its own test database (with a suffix such as _gw0, _gw1), so workers do not trample each other&#39;s data.</p><p>What usually breaks when you turn this on:</p><ul><li><strong>Tests that depend on order.</strong> A test that only passes because an earlier test created some data will fail randomly under xdist. These are real bugs in the test suite, and parallelism just exposes them. Fix them rather than turning parallelism off.</li><li><strong>Shared external state.</strong> Tests that write to the same file path, the same Redis key prefix or the same S3 bucket prefix will collide. Use temporary directories (tmp_path) and per-worker prefixes.</li><li><strong>Database service limits.</strong> If you run PostgreSQL as a service container, each worker opens its own connections. The defaults are normally fine for a handful of workers, but keep an eye on max_connections if you also run Celery tasks eagerly inside tests.</li></ul><p>Here is a backend job with a real PostgreSQL service and parallel tests:</p><pre>backend:<br>    runs-on: ubuntu-latest<br>    services:<br>      postgres:<br>        image: postgres:16<br>        env:<br>          POSTGRES_USER: app<br>          POSTGRES_PASSWORD: app<br>          POSTGRES_DB: app<br>        ports:<br>          - 5432:5432<br>        options: &gt;-<br>          --health-cmd &quot;pg_isready -U app&quot;<br>          --health-interval 5s<br>          --health-timeout 5s<br>          --health-retries 10<br>    env:<br>      DATABASE_URL: postgres://app:app@localhost:5432/app<br>      DJANGO_SETTINGS_MODULE: config.settings.test<br>    steps:<br>      - uses: actions/checkout@v4<br>      - uses: actions/setup-python@v5<br>        with:<br>          python-version: &quot;3.12&quot;<br>          cache: pip<br>          cache-dependency-path: requirements/*.txt<br>      - run: pip install -r requirements/dev.txt<br>      - run: pytest -n auto --durations=20</pre><p>The --durations=20 flag prints the twenty slowest tests at the end of each run. That list is your to-do list. In our experience a small number of slow tests (often ones that hit real network calls, sleep, or build large fixtures) account for a big share of the suite&#39;s time.</p><h3>3. Split tests across several jobs</h3><p>Parallelism inside one runner has a ceiling. When the suite is large enough, split it across several runners with a matrix. For Python, the pytest-split plugin divides tests into groups using recorded durations, so each group takes roughly the same time:</p><pre>backend-tests:<br>    runs-on: ubuntu-latest<br>    strategy:<br>      fail-fast: false<br>      matrix:<br>        group: [1, 2, 3, 4]<br>    steps:<br>      - uses: actions/checkout@v4<br>      - uses: actions/setup-python@v5<br>        with:<br>          python-version: &quot;3.12&quot;<br>          cache: pip<br>          cache-dependency-path: requirements/*.txt<br>      - run: pip install -r requirements/dev.txt<br>      - run: pytest --splits 4 --group ${{ matrix.group }} -n auto</pre><p>You generate the durations file locally (or in a scheduled job) with pytest --store-durations and commit it. Without it, the plugin still splits, just less evenly.</p><p>On the React side, both Jest (version 28 and later) and Vitest support sharding out of the box:</p><pre>frontend-tests:<br>    runs-on: ubuntu-latest<br>    strategy:<br>      fail-fast: false<br>      matrix:<br>        shard: [1, 2, 3]<br>    defaults:<br>      run:<br>        working-directory: frontend<br>    steps:<br>      - uses: actions/checkout@v4<br>      - uses: actions/setup-node@v4<br>        with:<br>          node-version: 22<br>          cache: npm<br>          cache-dependency-path: frontend/package-lock.json<br>      - run: npm ci<br>      - run: npx vitest run --shard=${{ matrix.shard }}/3</pre><p>Swap npx vitest run for npx jest if you use Jest; the --shard=1/3 syntax is the same.</p><p>A word of caution: more jobs means more setup overhead. Each shard checks out code and installs dependencies again. Splitting a three-minute suite into four shards can make the total slower. Split only when a single job is clearly the bottleneck, and check the measured wall-clock time afterwards.</p><h3>4. Skip work that has not changed</h3><p>In a repository that holds both a Django backend and a React frontend, many pull requests touch only one side. Running everything on every change is the easiest waste to remove.</p><p>GitHub Actions supports paths filters on triggers, but they apply to the whole workflow, and a workflow that never runs cannot report a status. That causes trouble with required checks. A more flexible pattern is to run a small &quot;changes&quot; job first and let the other jobs depend on its outputs. The <a href="https://github.com/dorny/paths-filter">dorny/paths-filter</a> action is a common choice:</p><pre>jobs:<br>  changes:<br>    runs-on: ubuntu-latest<br>    outputs:<br>      backend: ${{ steps.filter.outputs.backend }}<br>      frontend: ${{ steps.filter.outputs.frontend }}<br>    steps:<br>      - uses: actions/checkout@v4<br>      - uses: dorny/paths-filter@v3<br>        id: filter<br>        with:<br>          filters: |<br>            backend:<br>              - &#39;backend/**&#39;<br>              - &#39;requirements/**&#39;<br>              - &#39;.github/workflows/**&#39;<br>            frontend:<br>              - &#39;frontend/**&#39;<br>              - &#39;.github/workflows/**&#39;</pre><pre>  backend:<br>    needs: changes<br>    if: needs.changes.outputs.backend == &#39;true&#39;<br>    runs-on: ubuntu-latest<br>    steps:<br>      - uses: actions/checkout@v4<br>      # ... backend steps from above</pre><pre>  frontend:<br>    needs: changes<br>    if: needs.changes.outputs.frontend == &#39;true&#39;<br>    runs-on: ubuntu-latest<br>    steps:<br>      - uses: actions/checkout@v4<br>      # ... frontend steps from above</pre><p>Two rules keep this safe:</p><ul><li><strong>Include the workflow files themselves in every filter.</strong> If someone edits the CI configuration, everything should run.</li><li><strong>Always run the full suite on </strong><strong>main.</strong> Path filters are an optimisation for pull requests. Add a condition such as if: github.ref == &#39;refs/heads/main&#39; || needs.changes.outputs.backend == &#39;true&#39; so merges get the complete check.</li></ul><p>If you use branch protection with required checks, add a final “all green” job that needs every other job and passes when they either succeeded or were skipped. Make that the single required check, so skipped jobs do not block merges.</p><p>One more cheap win in the same spirit: cancel outdated runs when someone pushes again to the same pull request.</p><pre>concurrency:<br>  group: ci-${{ github.ref }}<br>  cancel-in-progress: ${{ github.ref != &#39;refs/heads/main&#39; }}</pre><p>This stops three stale runs from queuing behind the one you actually care about.</p><h3>5. Cache Docker layers</h3><p>If your pipeline builds a Docker image for Django (to run integration tests or to push to a registry), the build is often the slowest step. Docker BuildKit can store its layer cache in the GitHub Actions cache, so unchanged layers are reused across runs:</p><pre>image:<br>    runs-on: ubuntu-latest<br>    steps:<br>      - uses: actions/checkout@v4<br>      - uses: docker/setup-buildx-action@v3<br>      - uses: docker/build-push-action@v6<br>        with:<br>          context: .<br>          push: false<br>          tags: app:ci<br>          cache-from: type=gha<br>          cache-to: type=gha,mode=max</pre><p>The details are in Docker’s documentation for the <a href="https://docs.docker.com/build/cache/backends/gha/">GitHub Actions cache backend</a>. The cache only helps if your Dockerfile is ordered so that the expensive, rarely changing steps come first:</p><pre>FROM python:3.12-slim</pre><pre>WORKDIR /app</pre><pre># Dependencies change rarely: install them in their own layer<br>COPY requirements/base.txt requirements/base.txt<br>RUN pip install --no-cache-dir -r requirements/base.txt</pre><pre># Application code changes on every commit: copy it last<br>COPY . .</pre><pre>CMD [&quot;gunicorn&quot;, &quot;config.wsgi:application&quot;, &quot;--bind&quot;, &quot;0.0.0.0:8000&quot;]</pre><p>If you write COPY . . before installing requirements, every code change invalidates the dependency layer, and no cache backend can save you. Add a .dockerignore that excludes node_modules, .git, local media and virtual environments as well, so unrelated files do not bust the cache or bloat the build context.</p><h3>6. Keep the slow checks, move them</h3><p>Some checks are slow and valuable: end-to-end browser tests, full migrations against a production-sized schema, dependency audits. Instead of deleting them, decide where they belong.</p><ul><li><strong>On every pull request:</strong> linting, type checks, unit tests, and the tests for whatever changed.</li><li><strong>On merge to </strong><strong>main:</strong> the full suite, image build and integration tests.</li><li><strong>On a schedule (nightly):</strong> long end-to-end runs, dependency and security scans, and regenerating the test durations file.</li></ul><p>This is a judgement call, not a rule. Our view is that a pull request check should finish in under ten minutes, because past that point people stop waiting for it. Your team may set a different line, but pick one deliberately.</p><h3>7. Make the test setup itself cheaper</h3><p>Once the suite runs in parallel, per-test overhead starts to dominate, and Django has a few settings that cost nothing to change for tests:</p><pre># config/settings/test.py<br>from .base import *  # noqa<br> <br>PASSWORD_HASHERS = [&quot;django.contrib.auth.hashers.MD5PasswordHasher&quot;]<br>EMAIL_BACKEND = &quot;django.core.mail.backends.locmem.EmailBackend&quot;<br>CELERY_TASK_ALWAYS_EAGER = True<br>LOGGING_CONFIG = None</pre><p>The default password hasher is deliberately slow, which is exactly right in production and pure waste in a test suite that creates hundreds of users. A fast hasher is fine for tests because the hashes never leave the test database.</p><p>Database creation is the other fixed cost. pytest-django’s --nomigrations flag builds the test schema directly from the models instead of replaying every migration, which can save a minute on projects with a long migration history. If you use it, add a separate, cheap job that runs python manage.py makemigrations --check --dry-run and applies migrations to an empty database, so broken or missing migrations are still caught.</p><h3>8. Use faster tools where they are drop-in</h3><p>Some speedups come from swapping a tool rather than restructuring the pipeline. uv installs Python packages from a lock file many times faster than pip, and the official astral-sh/setup-uv action caches its own downloads. Ruff replaces flake8, isort and many pylint checks with a single fast binary, which turns a lint step from tens of seconds into a second or two. On the frontend, Vitest is usually faster to start than Jest for projects already built with Vite.</p><p>Treat these as optional. A tool swap is worth it when the step it replaces shows up near the top of your timings, not because it is fashionable. Measure the step before and after, the same way you measured the whole run.</p><h3>9. Deal with flaky tests before they deal with you</h3><p>Parallel and sharded runs expose tests that pass or fail depending on timing, ordering or shared state. A flaky test does more damage than a slow one: people learn to press re-run without reading the failure, and eventually a real failure gets re-run into green.</p><p>Track flakiness explicitly. When a test fails and then passes on retry with no code change, record it. Quarantine known flaky tests in a separate, non-blocking job with a ticket and an owner, so the main check stays trustworthy while they are fixed. Avoid blanket automatic retries on the main suite; they hide the problem instead of measuring it. The goal is a check that is both fast and believed.</p><h3>10. Buy bigger runners last, and with numbers</h3><p>Larger runners with more cores are the easiest speedup to buy, and the easiest to waste money on. They help when the suite is already parallel and CPU-bound: a test run that saturates four cores will finish faster on eight. They do almost nothing for a pipeline that spends its time downloading dependencies, waiting on a single slow test or building an image without a layer cache.</p><p>So apply the earlier steps first, then look at the timings again. If the remaining time is spread across tests running in parallel and the runner’s CPU is fully used, try a larger runner for that one job and compare the wall-clock time and the cost per run. Keep it only if both numbers justify it. Self-hosted runners are another option for teams with predictable load, but they bring their own maintenance and security work, especially for public repositories, so treat them as an infrastructure project rather than a quick fix.</p><h3>A realistic order of work</h3><p>If you are starting from a single, slow workflow, this is the order we would tackle it in:</p><ol><li>Measure the median run time and the slowest steps.</li><li>Turn on dependency caching for pip and npm.</li><li>Add concurrency with cancel-in-progress for pull requests.</li><li>Add pytest-xdist and fix the order-dependent tests it exposes.</li><li>Add path filters so frontend-only changes skip the backend job, and the other way around.</li><li>Reorder the Dockerfile and enable the BuildKit cache.</li><li>Only then, if one job is still the bottleneck, split it across a matrix.</li><li>Measure again and write the before and after numbers in the pull request that changed CI.</li></ol><p>Each step is small enough to review in one sitting, and each one can be reverted on its own if it causes trouble.</p><h3>Final thought</h3><p>Fast CI is not about a clever trick. It is about refusing to do the same work twice: do not download what you already have, do not test what did not change, do not rebuild layers that are identical, and do not run on one core what can run on four. Measure first, change one thing at a time, and keep the numbers. A pipeline that gives feedback in five minutes changes how a team works more than almost any tool you can buy.</p><p>If your team is fighting a slow pipeline on a Django and React stack, we are happy to compare notes in the comments: what was your biggest single win?</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3b78ca5f73ca" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[PostgreSQL 14 Is End-of-Life on November 12: An Upgrade Plan for Django Teams]]></title>
            <link>https://medium.com/@lycore/postgresql-14-is-end-of-life-on-november-12-an-upgrade-plan-for-django-teams-1e82f091e92c?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/1e82f091e92c</guid>
            <category><![CDATA[django]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[postgresql]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Wed, 11 Nov 2026 10:01:03 GMT</pubDate>
            <atom:updated>2026-11-11T10:01:03.205Z</atom:updated>
            <content:encoded><![CDATA[<p>No more security fixes after this week, and Django 6.1 already requires PostgreSQL 15. Here’s how to pick a target, choose an upgrade path, and rehearse it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r9WwWijzNj63XdkWgrzOVg.jpeg" /></figure><p>Tomorrow, November 12, 2026, PostgreSQL 14 reaches end of life. The <a href="https://www.postgresql.org/support/versioning/">official versioning page</a> lists November 12, 2026 as its final release date, and the project’s <a href="https://www.postgresql.org/developer/roadmap/">minor release roadmap</a> has a minor release scheduled for that same day. That makes it the last planned update PostgreSQL 14 will get.</p><p>If your Django app still runs on 14, nothing breaks on November 13. The database keeps serving queries the way it did the day before. That’s exactly why this deadline is easy to ignore, and why ignoring it is a mistake.</p><p>This post is a practical plan: what EOL actually means, how to decide where to go, three upgrade paths with honest tradeoffs, the Django-specific traps, and how to rehearse and roll back.</p><h3>What end-of-life actually means</h3><p>PostgreSQL supports each major version for five years. After that, the community ships one final minor release, and the version is unsupported. In practice:</p><ul><li><strong>No more security fixes.</strong> The next scheduled minor release (February 11, 2027) will patch 15, 16, 17, and 18. Not 14.</li><li><strong>No more bug or data-corruption fixes.</strong> Minor releases are where those fixes live, and there won’t be any more for 14.</li><li><strong>The ecosystem moves on.</strong> Frameworks and drivers drop old versions on their own schedule. For Django, that has already happened.</li></ul><p>Here’s the current picture from postgresql.org:</p><ul><li><strong>PostgreSQL 18</strong> (latest minor 18.6 at time of writing): supported until November 14, 2030</li><li><strong>PostgreSQL 17</strong> (17.11): supported until November 8, 2029</li><li><strong>PostgreSQL 16</strong> (16.15): supported until November 9, 2028</li><li><strong>PostgreSQL 15</strong> (15.19): supported until November 11, 2027</li><li><strong>PostgreSQL 14</strong> (14.24): supported until November 12, 2026</li></ul><p>PostgreSQL 19 hit its first release candidate on October 15, so it’s either brand new or just around the corner when you read this.</p><h3>The Django angle: 6.1 already left 14 behind</h3><p>The <a href="https://docs.djangoproject.com/en/6.1/releases/6.1/">Django 6.1 release notes</a> say it plainly: “Dropped support for PostgreSQL 14.” Django 6.1 supports PostgreSQL 15 and higher. Django 6.0 and the 5.2 LTS still support 14.</p><p>This isn’t just a documentation note. Django checks the server version when it connects, and on a 14 server Django 6.1 raises NotSupportedError (&quot;PostgreSQL 15 or later is required&quot;). So if your database is still on 14, your Django upgrade is blocked too. The database upgrade has to happen first.</p><h3>Managed services buy time, at a price</h3><p>If you’re on a managed service, the clock works differently, but it still runs:</p><ul><li><strong>Amazon RDS for PostgreSQL:</strong> the <a href="https://docs.aws.amazon.com/AmazonRDS/latest/PostgreSQLReleaseNotes/postgresql-release-calendar.html">RDS release calendar</a> lists the end of standard support for 14 as February 28, 2027. From March 1, 2027, instances still on 14 move into paid RDS Extended Support.</li><li><strong>Google Cloud SQL:</strong> the <a href="https://docs.cloud.google.com/sql/docs/postgres/db-versions">Cloud SQL version policy</a> shows extended support for 14 starting February 1, 2027, with enrollment and billing applied automatically.</li></ul><p>Extended support is a bridge, not a plan. You pay extra to postpone the same work.</p><h3>The decision checklist</h3><p>Answer these before you choose a path. Most of them are a single query or a single conversation.</p><ol><li><strong>Where does the database run?</strong> Self-managed VM, Kubernetes, RDS/Aurora, Cloud SQL, Azure? Managed services decide some of your options for you.</li><li><strong>How big is it, and how much downtime can you take?</strong> Five minutes at 3 a.m. and “zero, ever” lead to very different plans.</li><li><strong>Which extensions are installed?</strong> Run SELECT extname, extversion FROM pg_extension; in every database. pgvector, PostGIS, and anything custom must be available for the target version.</li><li><strong>Do you have streaming replicas?</strong> They need a plan too.</li><li><strong>Is the OS or base image changing at the same time?</strong> A new glibc can change collation order underneath your indexes (more on this below).</li><li><strong>Are data checksums enabled?</strong> Run SHOW data_checksums;. This matters for pg_upgrade to 18.</li><li><strong>Do any roles still use MD5 passwords?</strong> PostgreSQL 18 deprecates MD5 password authentication. Check with SELECT rolname FROM pg_authid WHERE rolpassword LIKE &#39;md5%&#39;;.</li><li><strong>Which Django and driver versions are you on, and where are you heading?</strong> If 6.1 is on your roadmap, the target needs to be 15 or newer anyway.</li></ol><h3>Which target version?</h3><p>My recommendation for most Django teams: <strong>go to 18 if your extensions and platform support it, otherwise 17.</strong></p><ul><li><strong>Not 15.</strong> It reaches EOL on November 11, 2027. You’d be doing this again in a year.</li><li><strong>16</strong> is fine, but it gives you two fewer years of support than 18 for the same migration effort.</li><li><strong>17</strong> is mature and widely supported by managed services and extension builds.</li><li><strong>18</strong> gives you the longest runway (to 2030) and real upgrade improvements: pg_upgrade now keeps optimizer statistics, and it adds the --swap transfer mode.</li><li><strong>19</strong> is fine to test against, but a brand-new major version is not where I’d land a production database under deadline pressure.</li></ul><p>You can jump straight from 14 to 18. Skipping versions in between is supported, but read the release notes for every major version you cross. 15, 16, 17, and 18 each have an “incompatibilities” section.</p><h3>Three upgrade paths</h3><h3>Path 1: pg_upgrade (in place, fast, short downtime)</h3><p><a href="https://www.postgresql.org/docs/current/pgupgrade.html">pg_upgrade</a> builds new system catalogs and reuses your existing data files, so it doesn’t depend much on data size. Always run the <strong>new</strong> version’s binary, and always run --check first:</p><pre># Initialize the new cluster with matching encoding/locale.<br># PG18 initdb enables data checksums by default and pg_upgrade requires<br># matching settings: keep --no-data-checksums only if SHOW data_checksums<br># returns &quot;off&quot; on your 14 cluster.<br>/usr/lib/postgresql/18/bin/initdb -D /srv/pg18/data \<br>  --encoding=UTF8 --locale=en_US.UTF-8 --no-data-checksums</pre><pre># Dry run: checks only, changes nothing.<br>/usr/lib/postgresql/18/bin/pg_upgrade \<br>  --old-bindir=/usr/lib/postgresql/14/bin \<br>  --new-bindir=/usr/lib/postgresql/18/bin \<br>  --old-datadir=/srv/pg14/data \<br>  --new-datadir=/srv/pg18/data \<br>  --link --check</pre><p>(Debian and Ubuntu users have pg_upgradecluster, which wraps this.)</p><p>Transfer modes are the main decision:</p><ul><li><strong>--copy (default):</strong> slowest, needs double the disk, and leaves the old cluster untouched.</li><li><strong>--link:</strong> uses hard links, so it&#39;s fast and uses almost no extra space. Once you start the new cluster, though, the old one is no longer safe to use.</li><li><strong>--clone:</strong> as fast as link on file systems with reflink support (for example XFS or Btrfs on Linux, APFS on macOS), and it leaves the old cluster usable.</li><li><strong>--swap (new in 18):</strong> moves the data directories, and can be the fastest option when you have many relations. The docs recommend pairing it with --sync-method=fsync. Once the file transfer starts, the old cluster is destructively modified.</li></ul><p>Pass the same mode flag together with --check, so pg_upgrade runs the checks specific to that mode.</p><p><strong>Tradeoffs:</strong> minutes of downtime, and it upgrades the cluster exactly as it is. That includes the OS and its collation behavior, which can be good or bad. Extensions need their new-version shared libraries installed before you run it.</p><h3>Path 2: logical replication (near-zero downtime, more moving parts)</h3><p>You stand up a new 18 server, replicate the data into it while 14 keeps serving traffic, then cut over in seconds. On the 14 side:</p><pre>-- Needs a restart on 14.<br>ALTER SYSTEM SET wal_level = logical;<br>-- After restart:<br>CREATE PUBLICATION upgrade_pub FOR ALL TABLES;</pre><p>Copy roles and schema with the new version’s tools, then subscribe:</p><pre>pg_dumpall -h old-db --roles-only | psql -h new-db -d postgres<br>pg_dump -h old-db -d app --schema-only --no-publications --no-subscriptions \<br>  | psql -h new-db -d app</pre><pre>-- On the 18 side:<br>CREATE SUBSCRIPTION upgrade_sub<br>  CONNECTION &#39;host=old-db dbname=app user=replicator&#39;<br>  PUBLICATION upgrade_pub;</pre><p>The documented <a href="https://www.postgresql.org/docs/current/logical-replication-restrictions.html">logical replication restrictions</a> are where Django teams get caught:</p><ul><li><strong>DDL is not replicated.</strong> Freeze migrations from the moment you dump the schema until cutover.</li><li><strong>Sequence data is not replicated.</strong> Rows arrive, but sequences on the new side still sit at their start values. You must advance them at cutover, or the first insert will hit a duplicate key.</li><li><strong>Large objects are not replicated.</strong> This rarely matters for Django, but check.</li><li><strong>UPDATE and DELETE need a replica identity.</strong> Django models have primary keys by default. Tables created by hand or by third-party tools might not.</li></ul><p><strong>Tradeoffs:</strong> downtime is limited to the cutover, and the old server stays intact as a fallback. In exchange, you have more steps, a migration freeze, and sequence handling to get right.</p><h3>Path 3: dump and restore (simple, slow, cleanest result)</h3><pre>pg_dumpall -h old-db --globals-only &gt; globals.sql<br>pg_dump -h old-db -d app -Fd -j 8 -f /backup/app<br>psql -h new-db -d postgres -f globals.sql<br>createdb -h new-db -O app_owner app<br>pg_restore -h new-db -d app -j 8 /backup/app<br>vacuumdb -h new-db --all --analyze-in-stages</pre><p>Use the directory format (-Fd) so both dump and restore can run in parallel.</p><p><strong>Tradeoffs:</strong> downtime grows with data size, so for small and medium databases this is often the simplest correct answer. It also rebuilds every index from scratch, which makes it the safest path when you’re changing OS, CPU architecture, or collation library at the same time.</p><h3>The Django-specific checklist</h3><p><strong>Settings and drivers.</strong> Django 6.1 requires psycopg 3.1.12+ or psycopg2 2.9.9+, and the <a href="https://docs.djangoproject.com/en/6.1/ref/databases/">Django database docs</a> recommend psycopg 3 and warn that psycopg2 support is likely to be deprecated. If you’re touching the database layer anyway, this is a good moment to switch. Then re-check OPTIONS: connection pooling (&quot;pool&quot;) only works with psycopg 3, and if PgBouncer runs in transaction mode, keep DISABLE_SERVER_SIDE_CURSORS = True on that connection.</p><p><strong>Migrations.</strong> Before the window, confirm nothing is pending, then freeze:</p><pre>python manage.py makemigrations --check --dry-run<br>python manage.py migrate --check</pre><p><strong>Raw SQL and </strong><strong>RunSQL.</strong> Search for hand-written SQL, especially functions used by expression indexes or materialized views. Since PostgreSQL 17, maintenance operations (ANALYZE, CREATE INDEX, REFRESH MATERIALIZED VIEW, VACUUM, and others) run functions with a safe search_path. Functions that reference non-default schemas need an explicit SET search_path in their definition.</p><p><strong>The public schema.</strong> Since PostgreSQL 15, new databases no longer give everyone CREATE on public. pg_upgrade and dump/restore preserve your existing permissions, but a freshly created database on 15+ won&#39;t have them. That includes a new staging environment. If your app role isn&#39;t the database owner, migrate fails with &quot;permission denied for schema public&quot;. Fix it by making the app role the owner (createdb -O) or with an explicit GRANT CREATE ON SCHEMA public TO app_role;.</p><p><strong>Collations and ICU.</strong> If the new server runs a different OS or glibc version, text sort order can change, and B-tree indexes on text columns can silently stop matching. The PostgreSQL wiki page on <a href="https://wiki.postgresql.org/wiki/Locale_data_changes">locale data changes</a> explains the risk. After the upgrade, compare recorded and actual collation versions:</p><pre>SELECT datname, datcollversion,<br>       pg_database_collation_actual_version(oid) AS actual<br>FROM pg_database WHERE datallowconn;</pre><p>If they differ, REINDEX the affected indexes, then run ALTER DATABASE app REFRESH COLLATION VERSION;. Do the same for any ICU collations you created with Django&#39;s CreateCollation operation or db_collation.</p><p><strong>Sequences.</strong> After a logical-replication cutover (or any partial data copy), let Django generate the fix:</p><pre>python manage.py sqlsequencereset accounts orders billing \<br>  | python manage.py dbshell</pre><p>List every app that has models, including auth, admin, and third-party apps.</p><p><strong>Extensions.</strong> Install the target-version builds before upgrading, then update them inside each database:</p><pre>ALTER EXTENSION vector UPDATE;          -- pgvector<br>SELECT postgis_extensions_upgrade();    -- PostGIS and its companions</pre><p>For pg_upgrade with PostGIS, make sure the same PostGIS 3.x release is available for both the old and new binaries.</p><p><strong>Statistics.</strong> Don’t let the first hour of production traffic hit a planner with no statistics. On 18, pg_upgrade carries most statistics over, and the docs recommend:</p><pre>vacuumdb --all --analyze-in-stages --missing-stats-only<br>vacuumdb --all --analyze-only</pre><p>When upgrading to 17 or earlier, or after a dump/restore, run vacuumdb --all --analyze-in-stages before you open the gates.</p><h3>The rehearsal plan</h3><p>Do the upgrade at least twice before you do it for real.</p><h3>Step 1: run your test suite against the target version in CI</h3><p>With <a href="https://testcontainers-python.readthedocs.io/en/latest/modules/postgres/README.html">Testcontainers for Python</a> and pytest-django, the server version becomes a single variable:</p><pre># conftest.py<br>import os<br>import pytest<br>from testcontainers.postgres import PostgresContainer</pre><pre>PG_IMAGE = os.environ.get(&quot;PG_IMAGE&quot;, &quot;postgres:18&quot;)</pre><pre>@pytest.fixture(scope=&quot;session&quot;)<br>def pg_container():<br>    with PostgresContainer(PG_IMAGE, driver=None) as pg:<br>        yield pg</pre><pre>@pytest.fixture(scope=&quot;session&quot;)<br>def django_db_modify_db_settings(pg_container):<br>    from django.conf import settings<br>    settings.DATABASES[&quot;default&quot;].update(<br>        HOST=pg_container.get_container_host_ip(),<br>        PORT=pg_container.get_exposed_port(5432),<br>        NAME=pg_container.dbname,<br>        USER=pg_container.username,<br>        PASSWORD=pg_container.password,<br>    )</pre><p>Run a CI matrix with PG_IMAGE set to postgres:14 and your target. If you use pgvector or PostGIS, use an image that ships the extension for that major version. A one-line guard test that compares connection.pg_version // 10000 with the major version in PG_IMAGE proves the matrix is really testing what you think it is.</p><h3>Step 2: rehearse on a copy of production</h3><p>Restore a recent backup to a staging server and run the exact commands from your runbook, including pg_upgrade --check and the post-upgrade scripts it generates. Time every step. The timings you measure are your maintenance window.</p><h3>Step 3: smoke-test the app against the upgraded copy</h3><p>Log in, run the critical flows, kick off Celery tasks, and compare row counts on your largest tables. Watch the slow-query log for plans that changed.</p><h3>Step 4: write down go/no-go criteria</h3><p>Do it before the night itself, not during it: maximum acceptable window, which smoke tests must pass, and who makes the call.</p><h3>The rollback plan</h3><p>Decide your point of no return in advance. It’s the moment the new server accepts writes.</p><ul><li><strong>Before anything else:</strong> take a fresh, verified backup or provider snapshot of the 14 cluster.</li><li><strong>pg_upgrade with </strong><strong>--copy or </strong><strong>--clone:</strong> the old cluster is untouched. Rolling back means pointing Django back and starting 14.</li><li><strong>pg_upgrade with </strong><strong>--link:</strong> if you haven&#39;t started the new cluster, the docs explain how to restore the old one (rename global/pg_control.old). Once the new cluster has started, the old one is unsafe, and rollback means restoring from backup.</li><li><strong>--swap:</strong> once pg_upgrade reports that the old cluster is no longer safe to start, rollback means restoring from backup.</li><li><strong>Logical replication:</strong> 14 stays intact. If you need a rollback that keeps writes made after cutover, set up reverse replication (new to old) before you open traffic, and rehearse that too.</li></ul><p>Whatever the path, keep the old DATABASES settings in an environment variable rather than in code. Then rolling back the app is a config change, not a deploy.</p><h3>Tell people before, during and after</h3><p>An upgrade that goes perfectly can still feel like an outage if nobody knew it was happening. Announce the window to support, to customer-facing teams and, if you have one, on your status page, a week ahead and again on the day. Say what users will notice: a few minutes of read-only mode, slower pages while statistics rebuild, or nothing at all.</p><p>During the window, post short updates in one channel so people stop asking in five. Afterwards, write a brief note: what version you are on now, how long the downtime was compared with the rehearsal, and anything you would change. That note is the first page of the runbook for the next major upgrade, which, with a yearly release cycle, is never as far away as it seems.</p><p>Keep that note short enough that people actually read it.</p><h3>Conclusion</h3><p>PostgreSQL 14 doesn’t stop working on November 12. It stops getting fixed. For Django teams there’s a second clock as well: Django 6.1 already requires PostgreSQL 15 or newer.</p><p>The plan is simple to state. Pick 18 (or 17), choose the path that fits your downtime budget, freeze migrations, handle sequences, extensions, collations, and statistics on purpose, and rehearse until the runbook is boring. A boring upgrade night is the goal.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1e82f091e92c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Multi-Tenant Django with PostgreSQL Row-Level Security: A Safety Net for Tenant Isolation]]></title>
            <link>https://medium.com/@lycore/multi-tenant-django-with-postgresql-row-level-security-a-safety-net-for-tenant-isolation-3e2ef18bdd95?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/3e2ef18bdd95</guid>
            <category><![CDATA[multitenancy]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[django]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[python]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Mon, 09 Nov 2026 07:01:01 GMT</pubDate>
            <atom:updated>2026-11-09T07:01:01.866Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vXG8QeKApSoT5e2Jdqx2cw.jpeg" /></figure><p>Most multi-tenant Django applications use shared tables: every tenant’s orders live in one orders_order table, and a tenant_id column says whose each row is. It is the cheapest model to run and the easiest to evolve. If you are still choosing between shared tables, schema-per-tenant and database-per-tenant, our <a href="https://www.lycore.com/technology/multi-tenant-architecture/">guide to multi-tenant architecture</a> walks through those trade-offs. This post assumes you have chosen shared tables.</p><p>The weakness of shared tables is that isolation depends on every query remembering to filter by tenant. Thousands of queries get it right; the one that doesn’t leaks a customer’s data to another customer. PostgreSQL’s row-level security (RLS) adds a second line of defence: once a policy is in place, the database itself refuses to return or modify rows that belong to a different tenant, whatever SQL the application sends.</p><h3>The gap that application-level filtering leaves</h3><p>A tenant-aware manager, one that adds filter(tenant=current_tenant) to every queryset, is a good first layer. It still leaves gaps:</p><ul><li><strong>Raw SQL and </strong><strong>extra()</strong> bypass managers completely. So does any hand-written query in a reporting module.</li><li><strong>Unscoped managers</strong> exist for good reasons, in the admin, in management commands, in data migrations, and they are easy to use from the wrong place.</li><li><strong>Aggregations and subqueries</strong> can cross tenants without anyone noticing. A Subquery over a related table, or an annotation that joins through a relation, filters only what you told it to filter.</li><li><strong>New developers</strong> don’t yet know which manager is safe, and code review won’t catch every case.</li></ul><p>Each of these is a single missing WHERE clause. RLS turns that missing clause from a data leak into an empty result.</p><h3>Use two database roles</h3><p>RLS does not apply to everyone. <a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html">PostgreSQL’s documentation</a> is explicit: superusers and roles with the BYPASSRLS attribute always bypass row security, and a table&#39;s owner normally does too. If your Django app connects as the role that owns its tables, as most do by default, enabling RLS changes nothing.</p><p>So split the roles:</p><ul><li>An <strong>owner role</strong> owns the tables and runs migrations.</li><li>An <strong>app role</strong> has SELECT, INSERT, UPDATE and DELETE on those tables and nothing more, and is subject to RLS.</li></ul><pre>CREATE ROLE app_owner LOGIN PASSWORD &#39;...&#39;;<br>CREATE ROLE app_user  LOGIN PASSWORD &#39;...&#39; NOSUPERUSER NOBYPASSRLS;</pre><pre>GRANT USAGE ON SCHEMA public TO app_user;</pre><pre>-- Tables and sequences created later by app_owner (i.e. by migrations)<br>-- are automatically usable by app_user.<br>ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public<br>    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;<br>ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public<br>    GRANT USAGE, SELECT ON SEQUENCES TO app_user;</pre><p>That means two sets of credentials. The simplest arrangement is to keep a single default database entry and choose the credentials by environment: the running application gets app_user, and the deploy step that runs migrate gets app_owner.</p><pre>DATABASES = {<br>    &quot;default&quot;: {<br>        &quot;ENGINE&quot;: &quot;django.db.backends.postgresql&quot;,<br>        &quot;NAME&quot;: env(&quot;DB_NAME&quot;),<br>        &quot;USER&quot;: env(&quot;DB_USER&quot;),          # app_user at runtime, app_owner for migrate<br>        &quot;PASSWORD&quot;: env(&quot;DB_PASSWORD&quot;),<br>        &quot;HOST&quot;: env(&quot;DB_HOST&quot;),<br>        &quot;CONN_MAX_AGE&quot;: 60,<br>    },<br>}</pre><h3>Add the policy in a migration</h3><p>Policies belong in migrations, so every environment gets them and they are reviewed like any other schema change.</p><pre>from django.db import migrations</pre><pre>POLICY = (<br>    &quot;tenant_id = NULLIF(current_setting(&#39;app.tenant_id&#39;, true), &#39;&#39;)::uuid&quot;<br>)<br></pre><pre>class Migration(migrations.Migration):<br>    dependencies = [(&quot;orders&quot;, &quot;0007_order_tenant&quot;)]</pre><pre>    operations = [<br>        migrations.RunSQL(<br>            sql=f&quot;&quot;&quot;<br>                ALTER TABLE orders_order ENABLE ROW LEVEL SECURITY;<br>                CREATE POLICY tenant_isolation ON orders_order<br>                    USING ({POLICY})<br>                    WITH CHECK ({POLICY});<br>            &quot;&quot;&quot;,<br>            reverse_sql=&quot;&quot;&quot;<br>                DROP POLICY IF EXISTS tenant_isolation ON orders_order;<br>                ALTER TABLE orders_order DISABLE ROW LEVEL SECURITY;<br>            &quot;&quot;&quot;,<br>        ),<br>    ]</pre><p>The two clauses do different jobs. USING decides which existing rows are visible to SELECT and which rows UPDATE and DELETE can touch; rows that fail it are silently left out. WITH CHECK is evaluated against the new contents of rows written by INSERT and UPDATE, and a failure raises an error instead of being silently skipped. Together they stop the app from reading another tenant&#39;s rows, and from writing a row into another tenant or moving one across by changing its tenant_id.</p><p>The expression reads the tenant from a custom setting, app.tenant_id, which the application sets per request (below). current_setting(name, true) returns NULL rather than raising when the setting doesn&#39;t exist, and NULLIF(..., &#39;&#39;) turns an empty value into NULL too, which matters because casting &#39;&#39; to uuid would be an error. A NULL tenant makes the comparison NULL, and PostgreSQL treats NULL like false: no rows are visible and every write fails the check. If the application forgets to set a tenant, the policy fails closed.</p><h3>FORCE ROW LEVEL SECURITY</h3><p>ALTER TABLE orders_order FORCE ROW LEVEL SECURITY makes policies apply to the table owner as well. It is the right tool when you can&#39;t separate roles, for example when a platform gives you a single database user, or when you want owner-run scripts to get the same protection as the app. Expect side effects: a data migration running as the owner will see no rows unless it sets a tenant first. Superusers and BYPASSRLS roles still bypass the policy, so FORCE does not replace the role split. It protects against one particular mistake.</p><h3>Policies on many tables</h3><p>Real schemas have dozens of tenant-scoped tables. Policy names only need to be unique per table, so the same SQL can be generated for each:</p><pre># tenancy/rls.py<br>from django.db import migrations</pre><pre>EXPR = &quot;tenant_id = NULLIF(current_setting(&#39;app.tenant_id&#39;, true), &#39;&#39;)::uuid&quot;<br></pre><pre>def tenant_policy(table):<br>    return migrations.RunSQL(<br>        sql=f&quot;&quot;&quot;<br>            ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;<br>            CREATE POLICY tenant_isolation ON {table}<br>                USING ({EXPR}) WITH CHECK ({EXPR});<br>        &quot;&quot;&quot;,<br>        reverse_sql=f&quot;&quot;&quot;<br>            DROP POLICY IF EXISTS tenant_isolation ON {table};<br>            ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;<br>        &quot;&quot;&quot;,<br>    )</pre><p>A migration then lists its tables: operations = [tenant_policy(t) for t in [&quot;orders_order&quot;, &quot;orders_orderline&quot;, &quot;billing_invoice&quot;]]. To make sure a new table never ships without a policy, add a test that asks the catalog for any table with a tenant_id column but RLS switched off:</p><pre>import pytest<br>from django.db import connection<br></pre><pre>@pytest.mark.django_db<br>def test_every_tenant_table_has_rls():<br>    with connection.cursor() as cursor:<br>        cursor.execute(&quot;&quot;&quot;<br>            SELECT c.relname<br>            FROM pg_class c<br>            JOIN pg_namespace n ON n.oid = c.relnamespace<br>            JOIN information_schema.columns col<br>              ON col.table_schema = n.nspname AND col.table_name = c.relname<br>            WHERE n.nspname = &#39;public&#39;<br>              AND c.relkind = &#39;r&#39;<br>              AND col.column_name = &#39;tenant_id&#39;<br>              AND NOT c.relrowsecurity<br>        &quot;&quot;&quot;)<br>        missing = [row[0] for row in cursor.fetchall()]<br>    assert missing == []</pre><p>Watch for tables this test can’t see: many-to-many through tables that Django generates have no tenant_id column. Either use an explicit through model that carries tenant_id, or accept that those rows are protected only through the tables they join.</p><h3>Set the tenant for each request</h3><p>The application tells PostgreSQL which tenant is active by setting app.tenant_id at the start of each request:</p><pre>from django.db import connection, transaction<br></pre><pre>class TenantMiddleware:<br>    def __init__(self, get_response):<br>        self.get_response = get_response</pre><pre>    def __call__(self, request):<br>        tenant = resolve_tenant(request)  # subdomain, header or session<br>        request.tenant = tenant<br>        if tenant is None:<br>            # No setting: tenant-scoped tables return nothing.<br>            return self.get_response(request)</pre><pre>        with transaction.atomic():<br>            with connection.cursor() as cursor:<br>                cursor.execute(<br>                    &quot;SELECT set_config(&#39;app.tenant_id&#39;, %s, true)&quot;,<br>                    [str(tenant.id)],<br>                )<br>            response = self.get_response(request)<br>            if response.status_code &gt;= 500:<br>                transaction.set_rollback(True)<br>            return response</pre><p>The third argument to <a href="https://www.postgresql.org/docs/current/functions-admin.html">set_config</a> makes the value transaction-local: it disappears when the transaction ends. That is the important design choice. With CONN_MAX_AGE, Django reuses database connections across requests, and PgBouncer in transaction pooling mode hands a server connection to a different client after each transaction. A session-level setting would outlive the request and could be picked up by the next one, which is the very leak we are trying to prevent. <a href="https://www.pgbouncer.org/features.html">PgBouncer&#39;s feature table</a> marks SET/RESET as never compatible with transaction pooling for this reason.</p><p>The trade-offs:</p><ul><li><strong>Every tenant request runs in one transaction.</strong> That’s the same behaviour as ATOMIC_REQUESTS. Slow requests hold their transaction open longer, so keep external calls (payment providers, email) out of request handlers where you can.</li><li><strong>Errors don’t roll back on their own.</strong> Django converts exceptions raised by a view into responses before they reach the surrounding middleware, so atomic() here sees a normal return, even for a 500. The set_rollback(True) line makes sure a failed request doesn&#39;t commit half its writes.</li><li><strong>Streaming responses lose the tenant.</strong> The body of a StreamingHttpResponse is generated after the middleware has returned and the transaction has ended. Django&#39;s transaction docs make the same point about streaming. Any query made while streaming sees no tenant rows. Load the data first, or set the tenant again inside the generator.</li><li><strong>Tenant resolution needs a readable table.</strong> resolve_tenant() runs before the setting exists, so the tenants table itself must not be behind this policy.</li></ul><h3>Background jobs and commands need the same context</h3><p>Celery tasks, management commands and shell sessions don’t go through middleware, so they need their own way to set the tenant:</p><pre>from contextlib import contextmanager</pre><pre>from django.db import connection, transaction<br></pre><pre>@contextmanager<br>def tenant_context(tenant_id):<br>    with transaction.atomic():<br>        with connection.cursor() as cursor:<br>            cursor.execute(&quot;SELECT current_setting(&#39;app.tenant_id&#39;, true)&quot;)<br>            previous = cursor.fetchone()[0] or &quot;&quot;<br>            cursor.execute(<br>                &quot;SELECT set_config(&#39;app.tenant_id&#39;, %s, true)&quot;, [str(tenant_id)]<br>            )<br>        yield<br>        # Releasing a savepoint does not undo a transaction-local setting,<br>        # so restore the outer value before leaving a nested block.<br>        with connection.cursor() as cursor:<br>            cursor.execute(&quot;SELECT set_config(&#39;app.tenant_id&#39;, %s, true)&quot;, [previous])</pre><p>The restore step at the end covers a subtle case. When tenant_context runs inside an existing transaction (for example inside a request, or inside a test), atomic() creates a savepoint. <a href="https://www.postgresql.org/docs/current/sql-set.html">PostgreSQL&#39;s </a><a href="https://www.postgresql.org/docs/current/sql-set.html">SET docs</a> say that rolling back to a savepoint cancels a local setting, but releasing one does not, so without the restore the inner tenant would stay active for the rest of the outer transaction. If the block raises, the savepoint is rolled back and the setting reverts by itself.</p><p>A Celery task then looks like this:</p><pre>from celery import shared_task<br></pre><pre>@shared_task<br>def send_invoice_reminders(tenant_id):<br>    with tenant_context(tenant_id):<br>        for invoice in Invoice.objects.filter(status=&quot;overdue&quot;):<br>            send_reminder(invoice)</pre><p>Pass the tenant ID as a string, never a model instance, and have a scheduler task fan out one task per tenant. If send_reminder makes slow network calls, collect the IDs inside the context and do the slow part afterwards, so the transaction stays short.</p><p>Some work really is cross-tenant: billing runs, platform-wide reports, support tooling. Give that work its own DATABASES entry that connects as a role with BYPASSRLS, so bypassing isolation is something you do on purpose, by name:</p><pre>DATABASES[&quot;cross_tenant&quot;] = {<br>    **DATABASES[&quot;default&quot;],<br>    &quot;USER&quot;: env(&quot;DB_CROSS_TENANT_USER&quot;),      # role with BYPASSRLS<br>    &quot;PASSWORD&quot;: env(&quot;DB_CROSS_TENANT_PASSWORD&quot;),<br>}<br>DATABASE_ROUTERS = [&quot;tenancy.routers.CrossTenantRouter&quot;]<br></pre><pre># tenancy/routers.py<br>class CrossTenantRouter:<br>    def db_for_read(self, model, **hints):<br>        return None    # nothing is routed to cross_tenant implicitly</pre><pre>    def db_for_write(self, model, **hints):<br>        return None</pre><pre>    def allow_migrate(self, db, app_label, model_name=None, **hints):<br>        return False if db == &quot;cross_tenant&quot; else None</pre><p>Code then opts in explicitly with Invoice.objects.using(&quot;cross_tenant&quot;), which is easy to search for and review. The router&#39;s allow_migrate keeps migrate from ever running through that alias. In tests, a second alias pointing at the same database needs some thought: Django&#39;s TEST[&quot;MIRROR&quot;] setting can redirect it to default, but it only works with TransactionTestCase, and it means the alias runs as the default role during tests.</p><h3>Keep application filtering, and test the boundary</h3><p>RLS is the safety net, not the main mechanism. Keep your tenant-aware managers: they make intent obvious in code, they give the query planner a direct tenant_id = ... condition, and they keep the application correct if it ever runs against a database without the policies.</p><p>Some performance and design notes:</p><ul><li><strong>Index for the policy.</strong> Make tenant_id the first column of composite indexes on tenant tables, for example (tenant_id, created_at), so the policy&#39;s condition and your query&#39;s ordering use the same index.</li><li><strong>Keep the policy expression simple.</strong> A column compared with a setting is cheap. Subqueries or function calls in a policy run as part of every query on the table, and the PostgreSQL docs warn that policies which look at other tables can also introduce race conditions.</li><li><strong>Check plans as the app role.</strong> PostgreSQL evaluates policy conditions before your query’s own conditions, except for functions marked leakproof, so a plan can differ between the owner and the app role. Run EXPLAIN as app_user with a tenant set.</li><li><strong>Scope unique constraints per tenant.</strong> Unique and foreign-key checks bypass RLS. A global unique email lets one tenant discover that an address exists in another tenant. Use UniqueConstraint(fields=[&quot;tenant&quot;, &quot;email&quot;], ...). For the same reason, validate that foreign keys point to rows in the same tenant.</li></ul><p>And test the boundary itself, against a real PostgreSQL database, connected as the restricted role:</p><pre>import pytest<br>from django.db import DatabaseError, connection, transaction<br></pre><pre>@pytest.fixture<br>def as_app_role(db):<br>    # The test connection&#39;s user must be a member of app_user.<br>    with connection.cursor() as cursor:<br>        cursor.execute(&quot;SET LOCAL ROLE app_user&quot;)<br></pre><pre>def test_other_tenants_orders_are_invisible(as_app_role, tenant_a, tenant_b):<br>    with tenant_context(tenant_b.id):<br>        other = Order.objects.create(tenant=tenant_b, total=100)</pre><pre>    with tenant_context(tenant_a.id):<br>        # Use the unscoped manager on purpose: only RLS stands in the way.<br>        assert not Order.all_tenants.filter(pk=other.pk).exists()<br></pre><pre>def test_cannot_write_into_another_tenant(as_app_role, tenant_a, tenant_b):<br>    with tenant_context(tenant_a.id):<br>        with pytest.raises(DatabaseError), transaction.atomic():<br>            Order.all_tenants.create(tenant=tenant_b, total=100)</pre><p>The first test proves that the database hides other tenants’ rows even when the application forgets to filter. The second proves that WITH CHECK blocks writes into another tenant. Run both as the app role. Run them as the owner or a superuser and they test nothing, because RLS doesn&#39;t apply.</p><h3>Checklist</h3><ul><li>The app connects as a role that is not superuser, not BYPASSRLS and not the table owner (or the tables use FORCE ROW LEVEL SECURITY).</li><li>Migrations run as the owner role, and default privileges grant the app role access to new tables.</li><li>Every tenant table has RLS enabled and a policy with both USING and WITH CHECK, enforced by a catalog test.</li><li>The tenant setting is transaction-local, set in middleware and in tenant_context for jobs and commands.</li><li>Failed requests roll back; streaming responses don’t query tenant tables after the middleware returns.</li><li>Cross-tenant work uses a separate, explicitly named connection with a BYPASSRLS role.</li><li>Unique constraints include tenant_id; foreign keys are validated within the tenant.</li><li>tenant_id leads composite indexes, and plans are checked as the app role.</li><li>Isolation tests run against PostgreSQL as the restricted role.</li></ul><h3>Conclusion</h3><p>Application-level filtering is how a multi-tenant Django app is supposed to work; row-level security is what keeps it safe when it doesn’t. The setup is modest: two roles, a policy per table in a migration, a transaction-local setting per request, and an explicit path for the work that really does span tenants. In return, a forgotten filter in a raw query, a report or a new developer’s first feature produces an empty result instead of another customer’s data. It does not remove the need for tenant-aware code or tests, but it means a single mistake no longer becomes a data breach.</p><p>Lycore builds multi-tenant SaaS platforms on Django, React, Flutter and .NET. Get in touch at lycore.com if you want a review of how your platform keeps customer data apart.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3e2ef18bdd95" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your AI Agent Needs Its Own Identity]]></title>
            <link>https://medium.com/@lycore/your-ai-agent-needs-its-own-identity-7089cc2daa62?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/7089cc2daa62</guid>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Fri, 06 Nov 2026 13:01:03 GMT</pubDate>
            <atom:updated>2026-11-06T13:01:03.407Z</atom:updated>
            <content:encoded><![CDATA[<h4>Agents are becoming services, so stop running them on a personal API key. Here’s a practical playbook for workload identity, short-lived tokens, scoped permissions and spend caps, based on what shipped in October 2026.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8K74NplOg_a4twLEDvCZPw.jpeg" /></figure><p>For most teams, the first production agent starts life on somebody’s laptop. It runs on that developer’s API key, their GitHub token and their SaaS login, and it works, so it ships. Six months later the developer changes teams. The agent is now opening pull requests, calling models and spending money under the name of someone who no longer owns it.</p><p>That used to be an awkward bit of tech debt. Since this month it’s also out of step with how the platforms themselves treat agents. Several releases in October 2026 point the same way: <strong>an agent is a workload, and a workload needs its own identity.</strong></p><p>In this post we’ll look at what changed, why it matters, and how to move an agent off human credentials one step at a time. The examples use Python, but the principles apply whatever your stack.</p><h3>This week’s reminder</h3><p>Binance is dealing with<a href="https://www.cnbc.com/2026/10/29/circle-tether-freeze-funds-impacted-by-potential-binance-security-issue.html"> a security incident that surfaced on October 29</a> and that it estimates at $6.3 billion in impact. According to the exchange, its initial investigation suggests that a compromised DevOps engineer’s laptop gave an attacker access to a service container, which then submitted unauthorized withdrawal transactions. Binance says it has found no evidence of a wallet private-key leak and has promised a detailed technical post-mortem, which has not been published yet, so the full chain of events isn’t known. Even the initial account shows the pattern this post is about: one person’s machine led to a workload that was able to submit withdrawals, and they went through. That’s the case for giving every service and agent its own narrowly scoped, short-lived identity, and for requiring a separate check before any high-risk action goes through.</p><p>A signed-in AI session is an identity worth stealing too: in October, a crafted ChatGPT share link could run code as the victim and read their private chats, until OpenAI fixed it on October 26 (<a href="https://www.parameter.ai/labs/we-found-a-gap-at-openai">Parameter Labs</a>).</p><h3>What shipped this month</h3><p>Four recent releases, each from its primary source:</p><ul><li><strong>Agents as code.</strong> On October 20, 2026, Cursor made its <a href="https://cursor.com/blog/agent-sdk">Agent SDK</a> generally available. It’s the framework Cursor uses for its own managed agents, and it treats an agent like any other software project: prompts in Markdown, tools as TypeScript functions, evals in the repo, and a single agent-sdk deploy command. Once agents live in repositories and deploy like services, the question &quot;whose credentials does this run on?&quot; can&#39;t be dodged.</li><li><strong>Workload-owned identity for Copilot agents.</strong> On October 27, 2026, GitHub announced <a href="https://github.blog/changelog/2026-10-27-github-copilot-supports-server-to-server-authentication/">server-to-server authentication for the Copilot SDK</a> in public preview. A GitHub App installed on an organization can make Copilot requests with a short-lived installation token. Usage is billed to the organization, and that organization’s model policies and budgets apply. GitHub’s own description is that this lets agents operate “without tying their authentication or usage to individual users.”</li><li><strong>Credential binding for MCP clients.</strong> On October 20, 2026, FastMCP published <a href="https://github.com/advisories/GHSA-vqqp-x5qm-5qp4">advisory GHSA-vqqp-x5qm-5qp4</a> along with version 4.1.2. In affected versions (4.0.0 through 4.1.1), the OAuth client-credentials providers could send a client secret to whatever authorization server an MCP server advertised. The fix adds an issuer parameter, and the maintainers say plainly that upgrading alone is not enough: you have to set it.</li><li><strong>Tighter egress and governance controls for hosted agents.</strong> The <a href="https://platform.claude.com/docs/en/release-notes/overview">Claude Platform release notes</a> for October list several governance changes for Claude Managed Agents. On October 7, limited networking began applying allowed_hosts to the web search and web fetch tools, and web_fetch became restricted to URLs that have already appeared in the session. On October 20, organization-level web fetch domain restrictions started applying to agent sessions, and &quot;user profiles&quot; launched so platforms can attribute requests to individual end customers.</li></ul><p>None of these is dramatic by itself, but together they mark a shift: vendors are building for agents that run unattended, with their own identities, budgets and network boundaries.</p><h3>The anti-pattern: an agent wearing a human’s badge</h3><p>Here’s what typically goes wrong when an agent runs on a person’s credentials.</p><ol><li><strong>Attribution is wrong.</strong> Audit logs say a human did something they never did. When an incident review asks “who merged this?”, the honest answer (“the triage bot, using Sara’s token”) isn’t in any log.</li><li><strong>Offboarding breaks production.</strong> Revoke the person’s access and the agent stops working. Keep their access so the agent keeps working, and you have a security finding.</li><li><strong>Blast radius is too big.</strong> A personal token usually carries everything that person can do. The agent needed read access to one repository and got write access to forty.</li><li><strong>Spend is invisible.</strong> Model usage lands on someone’s personal seat or a shared key, so you can’t see what a given agent costs, and you can’t cap it.</li><li><strong>Secrets never expire.</strong> A static API key sitting in a CI variable stays valid until someone remembers to rotate it, which in practice means never.</li></ol><p>All five have the same fix: give the agent a non-human identity of its own, issue it short-lived credentials, and scope those credentials tightly.</p><h3>Principle 1: one agent, one identity, per environment</h3><p>Start with an inventory. For every agent you run, write down:</p><ul><li>what it does and which events trigger it,</li><li>every system it calls (model APIs, Git hosting, MCP servers, internal APIs),</li><li>the credentials it uses today, and which human they belong to.</li></ul><p>Then create a dedicated non-human principal for each agent in each environment. On GitHub that’s a GitHub App. On the Claude API it’s a service account. On your cloud it’s an IAM role or a Kubernetes service account. Production and staging should never share an identity. If you can’t distinguish your staging agent from your production agent in an audit log, you’ll eventually be debugging the wrong one.</p><p>Anthropic’s documentation puts the distinction well: a workspace API key <em>is</em> a credential, whereas a service account <em>has</em> credentials. Credentials can be rotated and revoked. The identity, and the audit trail attached to it, stays.</p><h3>Principle 2: federate instead of storing keys</h3><p>A static key is a secret you have to create, distribute, rotate and eventually leak. Workload Identity Federation (WIF) removes that secret. Your workload already has an identity from its platform, such as a Kubernetes projected service-account token, a cloud metadata server or the GitHub Actions OIDC endpoint. It presents that signed JWT and exchanges it for a short-lived access token.</p><p>The Claude API has supported this since May 2026. You configure three resources: a <strong>service account</strong>, a <strong>federation issuer</strong> (your identity provider) and a <strong>federation rule</strong> that maps JWT claims to the service account. The rule also sets the token scope and lifetime: token_lifetime_seconds accepts 60 to 86,400 and defaults to 3,600. Per the <a href="https://platform.claude.com/docs/en/manage-claude/workload-identity-federation">Workload Identity Federation guide</a>, the SDK handles the exchange and the refresh:</p><pre>from anthropic import Anthropic, WorkloadIdentityCredentials, IdentityTokenFile</pre><pre>client = Anthropic(<br>    credentials=WorkloadIdentityCredentials(<br>        identity_token_provider=IdentityTokenFile(<br>            &quot;/var/run/secrets/anthropic.com/token&quot;<br>        ),<br>        federation_rule_id=&quot;fdrl_...&quot;,<br>        organization_id=&quot;00000000-0000-0000-0000-000000000000&quot;,<br>        service_account_id=&quot;svac_...&quot;,<br>        workspace_id=&quot;wrkspc_...&quot;,<br>    ),<br>)</pre><p>In production, the docs recommend the zero-argument form. You ship one container image everywhere and inject ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID and ANTHROPIC_IDENTITY_TOKEN_FILE per environment. The SDK re-reads the token file on every exchange, so rotated Kubernetes tokens are picked up automatically.</p><h3>The migration gotcha: a leftover key wins</h3><p>The documented credential precedence puts ANTHROPIC_API_KEY <em>above</em> the federation variables. If an old key is still in your container environment, a CI secret or a shell profile, it silently shadows federation. Everything looks migrated, but you&#39;re still using the static key. The documented migration path is:</p><ol><li>Configure federation alongside the existing key.</li><li>Run ant auth status inside the workload to see which credential source wins. At this stage it&#39;ll still be the API key.</li><li>Unset ANTHROPIC_API_KEY everywhere it&#39;s injected, then re-check ant auth status.</li><li>Delete the old key in the Console.</li></ol><p>We’d add a fifth step: make a CI check fail if ANTHROPIC_API_KEY reappears in a deployment manifest for a federated workload. Regressions here are quiet.</p><p>A second detail worth knowing: by default, identity tokens with a jti claim are single-use. If your workload fetches its own JWTs, a retry loop that re-presents the same token will be rejected. Mint a fresh JWT for each exchange.</p><h3>Principle 3: give the agent the smallest useful token</h3><p>Short-lived isn’t the same as least-privilege. A one-hour token with admin rights is still an admin token. Every mature platform lets you narrow tokens at mint time, so use that.</p><p>The <a href="https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens">server-to-server guide for the Copilot SDK</a> is a good template. You create a GitHub App with only the read-only <strong>Copilot Requests</strong> organization permission and install it on the organization that should pay. Then you mint an installation token that asks for exactly that permission:</p><pre>import time</pre><pre>import httpx<br>import jwt  # PyJWT</pre><pre>def mint_copilot_token(client_id: str, private_key: str, installation_id: int) -&gt; str:<br>    now = int(time.time())<br>    app_jwt = jwt.encode(<br>        {&quot;iat&quot;: now - 60, &quot;exp&quot;: now + 9 * 60, &quot;iss&quot;: client_id},<br>        private_key,<br>        algorithm=&quot;RS256&quot;,<br>    )<br>    resp = httpx.post(<br>        f&quot;https://api.github.com/app/installations/{installation_id}/access_tokens&quot;,<br>        headers={<br>            &quot;Accept&quot;: &quot;application/vnd.github+json&quot;,<br>            &quot;Authorization&quot;: f&quot;Bearer {app_jwt}&quot;,<br>        },<br>        json={&quot;permissions&quot;: {&quot;organization_copilot_requests&quot;: &quot;read&quot;}},<br>    )<br>    resp.raise_for_status()<br>    return resp.json()[&quot;token&quot;]</pre><p>Then pass the token explicitly and switch off fallback to whatever user credentials happen to be on the machine:</p><pre>import os</pre><pre>from copilot import CopilotClient, RuntimeConnection</pre><pre>client = CopilotClient(<br>    connection=RuntimeConnection.for_stdio(),<br>    github_token=os.environ[&quot;INSTALLATION_TOKEN&quot;],<br>    use_logged_in_user=False,<br>)</pre><p>The use_logged_in_user=False line matters more than it looks. The Copilot SDK&#39;s documented auth priority falls back through environment variables, stored CLI OAuth credentials and gh auth. On a developer machine or a shared runner, that fallback can quietly put your agent back on a human&#39;s identity. Turning it off makes a missing token fail loudly instead.</p><p>Installation tokens expire after one hour, so mint a new one before expiry and restart the SDK client with it. If the agent runs inside GitHub Actions, you don’t need an App at all. The docs show granting the built-in GITHUB_TOKEN the copilot-requests: write permission in the workflow, subject to an organization policy.</p><p>The same narrowing applies to repository access. When you <a href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app">generate an installation access token</a>, the repositories/repository_ids and permissions body parameters can only restrict what the installation already has, never widen it. An agent that labels issues in one repository should get a token for that repository with issues permission, and nothing else.</p><h3>Principle 4: bind credentials to where they may be sent</h3><p>The FastMCP advisory deserves attention beyond FastMCP users, because the bug class is general. A machine-to-machine OAuth client discovers its token endpoint from the MCP server’s metadata. If the client doesn’t check <em>which</em> authorization server it’s talking to, a malicious or compromised MCP server can advertise its own, and your client hands over its secret.</p><p>The advisory puts it in the same vulnerability class as separate advisories for the official Python and TypeScript MCP SDKs. With agents connecting to more and more MCP servers, some of them third-party, this is an easy mistake to repeat. The fix in FastMCP 4.1.2 is one line, but you have to write it:</p><pre>import os</pre><pre>from fastmcp import Client<br>from fastmcp.client.auth import ClientCredentialsOAuthProvider</pre><pre>auth = ClientCredentialsOAuthProvider(<br>    client_id=&quot;reporting-agent&quot;,<br>    client_secret=os.environ[&quot;REPORTING_AGENT_SECRET&quot;],<br>    scopes=[&quot;reports:read&quot;],<br>    issuer=&quot;https://auth.example.com&quot;,  # the AS that issued these credentials<br>)</pre><pre>async with Client(&quot;https://mcp.example.com/mcp&quot;, auth=auth) as client:<br>    tools = await client.list_tools()</pre><p>With issuer set, the provider only sends credentials to an authorization server whose discovered metadata names that issuer, and otherwise raises an OAuthFlowError. Leaving it out still works for compatibility but emits an MCPDeprecationWarning. We recommend turning that warning into an error in your test suite. If you ran an affected version against MCP servers you don&#39;t fully control, rotate those client secrets.</p><p>The general rule for any agent framework: <strong>every credential should know its legitimate destination</strong>, and the client should refuse to send it anywhere else.</p><h3>Principle 5: constrain egress, not just ingress</h3><p>Identity controls who the agent is. Network policy controls where it can reach, and for agents that read untrusted content, egress control is your main defense against data exfiltration through prompt injection.</p><p>The October Claude Managed Agents changes show the direction:</p><ul><li>With limited networking, the environment&#39;s allowed_hosts list now also governs web_search and web_fetch. A fetch to an unlisted host returns a url_not_allowed error to the agent.</li><li>web_fetch now only fetches URLs that have already appeared in the session, for example in a user message or a search result. A URL that shows up only in the model&#39;s own output or in a tool&#39;s output can&#39;t be fetched. That closes off a common trick where injected instructions build an exfiltration URL.</li><li>Organization admins can disable web fetch or set domain lists centrally, and agent sessions inherit those restrictions.</li></ul><p>If you run your own agent loop, copy the pattern: keep an explicit egress allowlist per agent, enforce it outside the model (in the HTTP client or a proxy, not in the prompt), and log every denied request. A denied request is often the first sign that something is trying to steer your agent.</p><h3>Principle 6: make every agent show up on the bill</h3><p>Once each agent has its own identity, cost attribution mostly takes care of itself, and you can add caps.</p><ul><li><strong>GitHub:</strong> with server-to-server auth, usage is billed to the organization that owns the App installation, under that organization’s budgets and spending controls. App-attributed usage appears in AI usage CSV reports with an app_client_id field, and owners can cut off an app by suspending or uninstalling it.</li><li><strong>Claude Managed Agents:</strong> since August 7, 2026, a session can carry a hard budget priced at public list rates. When the budget is reached, the session pauses with a budget_reached stop reason instead of starting new model requests. Deployments apply the budget to every session they start.</li><li><strong>Multi-tenant platforms:</strong> if you serve your own customers through a model API, the new Claude user profiles let you tell Anthropic which end customer each request is for. That way a safety enforcement action can apply to one customer instead of your whole organization.</li></ul><p>Our rule of thumb: if you can’t answer “what did this agent cost last week, and what stops it costing ten times that next week?”, it isn’t ready for unattended operation.</p><h3>A migration checklist</h3><p>Here’s the order we’d tackle this in for an existing agent:</p><ol><li><strong>Inventory</strong> every agent, its triggers, the systems it touches and the human credentials it borrows.</li><li><strong>Create a non-human identity</strong> per agent per environment: GitHub App, model-provider service account, cloud role.</li><li><strong>Federate where you can.</strong> Use your platform’s OIDC token instead of a stored key, and keep token lifetimes short.</li><li><strong>Remove the fallbacks.</strong> Unset leftover API keys, disable logged-in-user credential discovery, and confirm with the tool’s auth-status command.</li><li><strong>Scope tokens at mint time</strong> to the permissions and repositories the agent actually needs.</li><li><strong>Bind OAuth credentials to their issuer</strong> in every MCP or OAuth client, and treat binding warnings as errors.</li><li><strong>Allowlist egress</strong> outside the model, and log denials.</li><li><strong>Set budgets</strong> per agent or per session, and review attributed usage weekly.</li><li><strong>Write the kill switch down.</strong> Know exactly which App to suspend or which service account to disable, and test that it works.</li></ol><p>You don’t need to do all nine before the next release. Steps 2 and 4 alone fix most of the real-world risk.</p><h3>Conclusion</h3><p>The agent tooling released this month (agents defined as code, workload-owned Copilot identities, issuer-bound MCP credentials, egress-aware hosted agents) all assumes your agents are services, not extensions of a person. Our credentials, permissions and budgets should make the same assumption.</p><p>The work isn’t glamorous, and most of it is configuration rather than code. It is what lets you answer the questions that come up the first time an agent does something unexpected: which agent was it, what was it allowed to do, what did it cost, and how do we stop it? If you can answer those for every agent you run, you’re ahead of most teams. If you can’t, pick your busiest agent and give it its own identity this week.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7089cc2daa62" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[When React Crashes: Error Boundaries and Error Reporting for Production Apps]]></title>
            <link>https://medium.com/@lycore/when-react-crashes-error-boundaries-and-error-reporting-for-production-apps-1f966f636cd9?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/1f966f636cd9</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[frontend-development]]></category>
            <category><![CDATA[react]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[javascript]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Thu, 05 Nov 2026 05:05:55 GMT</pubDate>
            <atom:updated>2026-11-05T05:05:55.213Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XuRy5baG68E74kgr2_g3aA.jpeg" /></figure><p>By default, when a component throws while React is rendering, React removes the whole UI from the screen. The user sees a blank page, and you see nothing, because in React 19 the way errors are reported also changed. If your monitoring quietly stopped seeing some errors after an upgrade, that is why.</p><p>This post covers the pieces that decide what happens when React crashes: error boundaries, the three root error callbacks in React 19, the errors boundaries do not catch, how to reset a boundary, and how to test all of it. The code was run with React 19.3.1, react-error-boundary 6.1.6 and Vitest 4.1.12 with Testing Library in jsdom. The Sentry snippet comes from Sentry’s docs and was not run. The /client-errors endpoint is made up.</p><h3>What an error boundary is</h3><p>An <a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary">error boundary</a> is a component that catches errors thrown while rendering its children and shows a fallback instead. React still requires a class component for this. The docs say there is currently no way to write one as a function component, and point to react-error-boundary instead. Here is a minimal one:</p><pre>import { Component } from &quot;react&quot;;</pre><pre>export class ErrorBoundary extends Component {<br>  state = { error: null };</pre><pre>  static getDerivedStateFromError(error) {<br>    return { error }; // render the fallback on the next pass<br>  }</pre><pre>  componentDidCatch(error, info) {<br>    this.props.onError?.(error, info.componentStack);<br>  }</pre><pre>  reset = () =&gt; {<br>    this.props.onReset?.();<br>    this.setState({ error: null });<br>  };</pre><pre>  render() {<br>    if (this.state.error) {<br>      return this.props.fallback({<br>        error: this.state.error,<br>        reset: this.reset,<br>      });<br>    }<br>    return this.props.children;<br>  }<br>}</pre><p>getDerivedStateFromError switches the state so the next render shows the fallback. componentDidCatch receives info.componentStack, a list of the components between the crash and the boundary. That stack is the most useful thing you can attach to a report, because the JavaScript stack alone rarely tells you which screen was involved. In development, the docs example also logs React.captureOwnerStack(), which adds the owner stack, and warns that it is not available in production.</p><p>In practice you do not need to maintain this class. The <a href="https://github.com/bvaughn/react-error-boundary">react-error-boundary</a> package wraps it, and adds the things you would end up writing anyway: fallback, fallbackRender and FallbackComponent for the fallback UI, onError and onReset callbacks, a resetKeys prop, and a useErrorBoundary hook. Version 6 targets ES modules, and its README points projects that cannot use them to version 5.</p><h3>Where to put boundaries</h3><p>One boundary around the whole app is better than none, but it turns every bug into a full-page failure. The React docs suggest thinking about where it makes sense to show an error message, and give a messaging app as an example. I use three layers:</p><ul><li><strong>Root:</strong> a last-resort fallback for the whole page. It should be plain and have a reload button.</li><li><strong>Route or page:</strong> keeps the header and navigation alive when one screen crashes. Pass the pathname as a reset key so moving to another page clears the error.</li><li><strong>Widget:</strong> independent panels such as a comments list, a chart or a recommendations rail. A crash there should not take the rest of the page with it.</li></ul><pre>import { ErrorBoundary, useErrorBoundary } from &quot;react-error-boundary&quot;;<br>import { useTransition } from &quot;react&quot;;<br>import { AppFallback, WidgetFallback } from &quot;./Fallbacks.jsx&quot;;</pre><pre>export function Profile({ userId, load }) {<br>  const user = load(userId); // throws if the data is bad<br>  return &lt;p&gt;Profile: {user.name}&lt;/p&gt;;<br>}</pre><pre>export function Dashboard({ userId, load }) {<br>  return (<br>    &lt;ErrorBoundary FallbackComponent={AppFallback}&gt;<br>      &lt;h1&gt;Dashboard&lt;/h1&gt;<br>      &lt;ErrorBoundary<br>        FallbackComponent={WidgetFallback}<br>        resetKeys={[userId]}<br>      &gt;<br>        &lt;Profile userId={userId} load={load} /&gt;<br>      &lt;/ErrorBoundary&gt;<br>      &lt;ErrorBoundary FallbackComponent={WidgetFallback}&gt;<br>        &lt;p&gt;Recent activity&lt;/p&gt;<br>      &lt;/ErrorBoundary&gt;<br>    &lt;/ErrorBoundary&gt;<br>  );<br>}</pre><p>One rule: a boundary cannot catch errors thrown by itself, which the docs list explicitly. So the fallback must be dumb. No data fetching, no components that might throw. And keep a boundary above any boundary whose fallback does real work.</p><p>Also think about what you wrap. Put the boundary around the widget, not around a form with unsaved input, or a crash will throw away what the user typed.</p><h3>Resetting a boundary</h3><p>There are three ways a boundary leaves its error state:</p><ol><li><strong>The user clicks “Try again”.</strong> The fallback receives resetErrorBoundary, which clears the error and renders the children again.</li><li><strong>resetKeys change.</strong> In the example above, resetKeys={[userId]} clears the error as soon as the user ID changes. That is the right model when the failure is tied to some input.</li><li><strong>onReset runs</strong> whenever the boundary resets. Use it to clear whatever caused the failure, such as a bad cache entry or a stuck query.</li></ol><p>Resetting renders the same children again. If the crash is deterministic, the user gets the fallback again, so do not reset automatically on a timer. Let a person decide, or tie the reset to a change in input.</p><h3>What boundaries do not catch</h3><p>This is where most “but I have an error boundary” bugs come from. According to the React docs, error boundaries do not catch errors in:</p><ul><li>event handlers</li><li>server-side rendering</li><li>the error boundary itself</li><li>asynchronous code such as setTimeout or requestAnimationFrame callbacks, with one exception: errors thrown inside the function you pass to the startTransition returned by useTransition are caught</li></ul><p>I checked the event handler case in a test. A button that throws in onClick inside a boundary does not show the fallback, and in my jsdom run the error showed up as a global error event on window. If you want the failed action to take over the UI, send the error to the boundary yourself with useErrorBoundary, or use a transition:</p><pre>export function SaveButton({ save }) {<br>  const { showBoundary } = useErrorBoundary();<br>  return (<br>    &lt;button<br>      onClick={async () =&gt; {<br>        try {<br>          await save();<br>        } catch (error) {<br>          showBoundary(error); // hand the error to the nearest boundary<br>        }<br>      }}<br>    &gt;<br>      Save<br>    &lt;/button&gt;<br>  );<br>}</pre><pre>export function SaveWithTransition({ save }) {<br>  const [pending, startTransition] = useTransition();<br>  return (<br>    &lt;button<br>      disabled={pending}<br>      onClick={() =&gt; startTransition(async () =&gt; await save())}<br>    &gt;<br>      Save<br>    &lt;/button&gt;<br>  );<br>}</pre><p>Both versions work in the tests, and both show the nearest fallback after a rejected save(). The difference is intent. A try/catch with a toast is often the better answer for a failed save, because the user keeps their screen. Reserve showBoundary for failures that leave the component unable to continue.</p><p><strong>Server rendering</strong> has its own hooks. With renderToPipeableStream, the <a href="https://react.dev/reference/react-dom/server/renderToPipeableStream">docs</a> describe onError as firing on every server error, recoverable or not. By default it only calls console.error, so if you override it to send crash reports, keep calling console.error too. onShellError fires if the initial shell fails to render, before any bytes are sent, so that is where you return a fallback HTML page.</p><h3>Reporting: the three root callbacks</h3><p>React 19 changed what happens to errors. The <a href="https://react.dev/blog/2024/04/25/react-19-upgrade-guide">upgrade guide</a> says errors in render are no longer re-thrown. Errors that no boundary caught go to window.reportError. Errors that a boundary caught go to console.error. If your production reporting relied on re-thrown errors, you may need to update it.</p><p>The consequence is easy to miss. <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/reportError">window.reportError()</a> emulates an uncaught exception for global handlers, which is what a monitoring SDK listens to. An error caught by a boundary only reaches console.error, so it never gets reported unless you do it yourself. React 19 adds three options to <a href="https://react.dev/reference/react-dom/client/createRoot">createRoot</a> and hydrateRoot for this. Each receives the error and an errorInfo object that contains componentStack:</p><ul><li>onCaughtError: an error was caught by an error boundary</li><li>onUncaughtError: an error was thrown and no boundary caught it</li><li>onRecoverableError: React recovered from an error on its own</li></ul><pre>import { createRoot } from &quot;react-dom/client&quot;;<br>import App from &quot;./App.jsx&quot;;<br>import { report } from &quot;./report.js&quot;;</pre><pre>// In development, leave these out so React&#39;s default logging and your<br>// overlay keep working.<br>const errorOptions =<br>  process.env.NODE_ENV === &quot;production&quot;<br>    ? {<br>        onCaughtError: (error, info) =&gt; report(error, info, &quot;caught&quot;),<br>        onUncaughtError: (error, info) =&gt; report(error, info, &quot;uncaught&quot;),<br>        onRecoverableError: (error, info) =&gt; report(error, info, &quot;recoverable&quot;),<br>      }<br>    : {};</pre><pre>createRoot(document.getElementById(&quot;root&quot;), errorOptions).render(&lt;App /&gt;);</pre><p>The reporting function sends the pieces you will want when triaging:</p><pre>const ENDPOINT = &quot;/client-errors&quot;;</pre><pre>export function report(error, info, source) {<br>  const body = JSON.stringify({<br>    source, // &quot;caught&quot; | &quot;uncaught&quot; | &quot;recoverable&quot;<br>    message: error instanceof Error ? error.message : String(error),<br>    stack: error instanceof Error ? error.stack : undefined,<br>    componentStack: info?.componentStack,<br>    url: window.location.href,<br>  });<br>  navigator.sendBeacon?.(ENDPOINT, body);<br>}</pre><p>Three details from the code. First, the docs recommend leaving these options out in development, so React’s default logging and your overlay keep working. Second, <a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon">sendBeacon</a> is meant for diagnostics like this, and it exists for the case where the browser may be about to unload the page. Third, source tells you whether the user saw a fallback (caught), a blank screen (uncaught) or nothing at all (recoverable). That distinction is how you decide what to fix first. I would send recoverable errors at low severity and watch for a spike after a deploy.</p><p>If you use Sentry, its docs show the same three options wired up with Sentry.reactErrorHandler():</p><pre>import * as Sentry from &quot;@sentry/react&quot;;<br>import { createRoot } from &quot;react-dom/client&quot;;</pre><pre>const root = createRoot(document.getElementById(&quot;root&quot;), {<br>  onUncaughtError: Sentry.reactErrorHandler(),<br>  onCaughtError: Sentry.reactErrorHandler(),<br>  onRecoverableError: Sentry.reactErrorHandler(),<br>});</pre><p><a href="https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/">Sentry’s guide</a> describes this as a global safety net with no UI handling, and its ErrorBoundary component as the scoped option that also renders a fallback. I did not test Sentry itself. If you use both, read its docs on how duplicate events are avoided.</p><h4>Do not report twice</h4><p>Reporting from a boundary’s onError and from the root onCaughtError sends every caught error twice. I confirmed that both callbacks fire once for the same error. I prefer to report from the root callbacks only, because that covers every boundary, including ones inside third-party libraries. Use a boundary&#39;s onError for local side effects only.</p><p>Whatever you use, check three things before shipping. Upload source maps, because production stacks are usually minified. Scrub the message, because errors often contain user data. And dedupe or throttle, because a component that crashes on every render can produce a flood of identical reports.</p><h3>Fallback UX</h3><p>A fallback is a screen in your product, so design it like one:</p><ul><li>Say what failed in the user’s terms (“This panel could not load”), not what the exception was. Keep stack traces out of production fallbacks.</li><li>Offer a next step. “Try again” for a widget, a reload for the root, a link home for a route.</li><li>Keep the layout stable. A fallback with the same height as the widget stops the page from jumping.</li><li>Use role=&quot;alert&quot; so assistive technology announces it, as react-error-boundary&#39;s own example does.</li><li>Give support something to work with. If you can attach a short reference ID to the report and show it in the fallback, “it said A7F2” is a much better bug report than “it broke”.</li></ul><pre>export function WidgetFallback({ resetErrorBoundary }) {<br>  return (<br>    &lt;div role=&quot;alert&quot;&gt;<br>      &lt;p&gt;This panel could not load.&lt;/p&gt;<br>      &lt;button onClick={resetErrorBoundary}&gt;Try again&lt;/button&gt;<br>    &lt;/div&gt;<br>  );<br>}</pre><pre>export function AppFallback() {<br>  return (<br>    &lt;div role=&quot;alert&quot;&gt;<br>      &lt;h1&gt;Something went wrong&lt;/h1&gt;<br>      &lt;p&gt;Reload the page. If it keeps happening, contact support.&lt;/p&gt;<br>      &lt;button onClick={() =&gt; window.location.reload()}&gt;Reload&lt;/button&gt;<br>    &lt;/div&gt;<br>  );<br>}</pre><h3>Testing error handling</h3><p>Error handling that is never tested usually fails the first time it matters. These tests cover the layers from this post:</p><pre>const load = (id) =&gt; {<br>  if (id === &quot;bad&quot;) throw new Error(&quot;corrupt profile&quot;);<br>  return { name: `user ${id}` };<br>};</pre><pre>describe(&quot;placement and reset&quot;, () =&gt; {<br>  it(&quot;keeps the rest of the page alive when one widget crashes&quot;, () =&gt; {<br>    render(&lt;Dashboard userId=&quot;bad&quot; load={load} /&gt;);<br>    expect(screen.getByRole(&quot;alert&quot;).textContent).toContain(&quot;could not load&quot;);<br>    expect(screen.getByText(&quot;Dashboard&quot;)).toBeTruthy();<br>    expect(screen.getByText(&quot;Recent activity&quot;)).toBeTruthy();<br>  });</pre><pre>  it(&quot;resets on its own when resetKeys change&quot;, () =&gt; {<br>    const { rerender } = render(&lt;Dashboard userId=&quot;bad&quot; load={load} /&gt;);<br>    expect(screen.getByRole(&quot;alert&quot;)).toBeTruthy();<br>    rerender(&lt;Dashboard userId=&quot;42&quot; load={load} /&gt;);<br>    expect(screen.queryByRole(&quot;alert&quot;)).toBeNull();<br>    expect(screen.getByText(&quot;Profile: user 42&quot;)).toBeTruthy();<br>  });</pre><p>They check that a crash is contained to one widget and that a changed key clears it. To check that they can fail, I removed resetKeys from the boundary, and the reset test failed.</p><p>To test the root callbacks, create the root yourself:</p><pre>// No act() here: act() rethrows render errors to the test. Outside of it,<br>  // React behaves like it does in the browser and calls the root handlers.<br>  async function mount(ui, options) {<br>    const container = document.createElement(&quot;div&quot;);<br>    const root = createRoot(container, options);<br>    globalThis.IS_REACT_ACT_ENVIRONMENT = false;<br>    root.render(ui);<br>    await new Promise((resolve) =&gt; setTimeout(resolve, 50)); // let React commit<br>    return root;<br>  }</pre><pre>  it(&quot;onCaughtError fires for an error a boundary caught, with componentStack&quot;, async () =&gt; {<br>    const onCaughtError = vi.fn();<br>    const onUncaughtError = vi.fn();<br>    await mount(<br>      &lt;ErrorBoundary fallback={&lt;p&gt;fallback&lt;/p&gt;}&gt;<br>        &lt;Bomb /&gt;<br>      &lt;/ErrorBoundary&gt;,<br>      { onCaughtError, onUncaughtError },<br>    );<br>    expect(onCaughtError).toHaveBeenCalledOnce();<br>    const [error, info] = onCaughtError.mock.calls[0];<br>    expect(error.message).toBe(&quot;render failed&quot;);<br>    expect(info.componentStack).toContain(&quot;Bomb&quot;);<br>    expect(onUncaughtError).not.toHaveBeenCalled();<br>  });</pre><p>Four testing tips from running these:</p><ul><li>React 19 logs caught errors with console.error, so spy on it and keep the test output readable.</li><li>render from Testing Library rethrows an uncaught render error, because act does. I confirmed that expect(() =&gt; render(&lt;Bomb /&gt;)).toThrow() passes. When you want React to call onUncaughtError the way it does in a browser, call createRoot directly and skip act.</li><li>Assert on componentStack, not just the message. A report without it is much harder to act on.</li><li>Test the reset path as well as the failure path, since that is the one users click.</li></ul><h3>Checklist</h3><ul><li>A root boundary with a plain fallback, plus boundaries at route and widget level.</li><li>resetKeys on anything tied to an ID or route, and a &quot;Try again&quot; button everywhere else.</li><li>Event handlers and async code handle their own errors, or call showBoundary.</li><li>onCaughtError, onUncaughtError and onRecoverableError report to one place, only in production.</li><li>Reports carry componentStack, route, release and a source label, with source maps uploaded and PII scrubbed.</li><li>Server rendering has onError and onShellError set.</li><li>Tests cover containment, reset and the root callbacks.</li></ul><h3>Conclusion</h3><p>An error boundary does one job: it decides how much of the screen a crash takes with it. Everything else is on you. Put boundaries where a partial page is still useful, report from the root callbacks so nothing slips past, handle event and async errors where they happen, and test the fallback like any other screen. The moment to find out your reporting has a gap is a Tuesday afternoon in a test run, not the morning after a release.</p><p><em>Lycore builds and maintains production web and mobile apps in React, Flutter, Django and .NET.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1f966f636cd9" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[When Django Signals Become a Liability: Moving Side Effects Into Service Functions]]></title>
            <link>https://medium.com/@lycore/when-django-signals-become-a-liability-moving-side-effects-into-service-functions-3d2b3d19a633?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/3d2b3d19a633</guid>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[django]]></category>
            <category><![CDATA[software-architecture]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Wed, 04 Nov 2026 10:01:03 GMT</pubDate>
            <atom:updated>2026-11-04T10:01:03.125Z</atom:updated>
            <content:encoded><![CDATA[<p>Why post_save receivers are a poor home for business rules, and how to move them into explicit, transactional service functions without a big-bang rewrite.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*iAsKdQxv4jC20xM_" /></figure><p>A developer changes how orders are saved in an admin action and, a day later, finance asks why three hundred customers received a second “your order has shipped” email. Nobody touched the email code. It turns out a post_save receiver on Order sends that email whenever the status field is “shipped”, and the new admin action saved every selected order twice.</p><p>Django signals are appealing early in a project. They let you react to model changes without editing the code that makes them, and the first few receivers feel clean and decoupled. Over time they become the place where important business logic hides: invisible from the call site, triggered by saves from all over the codebase, and easy to break without knowing.</p><p>This post covers where signals cause trouble, what we use instead for business side effects, how to migrate without a rewrite, and the cases where signals are still the right tool.</p><p>Photo by <a href="https://unsplash.com/@sasun1990">Sasun Bughdaryan</a> on <a href="https://unsplash.com">Unsplash</a></p><h3>The receiver behind the duplicate emails</h3><p>It helps to look at the code that caused the incident. It’s the kind of receiver most Django projects have somewhere:</p><pre># orders/signals.py<br>from django.db.models.signals import post_save<br>from django.dispatch import receiver</pre><pre>from notifications.tasks import send_shipping_email<br>from .models import Order<br></pre><pre>@receiver(post_save, sender=Order)<br>def notify_customer_on_ship(sender, instance, created, **kwargs):<br>    if instance.status == Order.Status.SHIPPED:<br>        send_shipping_email.delay(instance.pk)</pre><p>It reads fine in isolation. The problem is what it actually checks: not “the order has just shipped” but “the order is saved while its status is shipped”. Those are the same thing only as long as every code path saves a shipped order exactly once. Fixing a typo in the address of a shipped order sends the email again. So does an admin action that saves twice.</p><p>The usual patch is to remember the previous status in pre_save or __init__ and compare. That works until someone writes with update(), which skips both signals, or reloads the instance between the two saves. Each patch makes the receiver more clever, and none of it is visible from the code that triggers the email.</p><h3>Why signals get harder to live with</h3><p>The problems all come from the same property: the code that triggers the behaviour doesn’t mention it. Django’s own <a href="https://docs.djangoproject.com/en/stable/topics/signals/">signals documentation</a> now carries a warning to that effect:</p><blockquote><em>Signals give the appearance of loose coupling, but they can quickly lead to code that is hard to understand, adjust and debug.</em></blockquote><p>In practice, that shows up in a few specific ways:</p><ul><li><strong>The call site tells you nothing.</strong> Reading order.save() doesn’t reveal the emails, stock updates and webhooks that follow. You have to search the codebase for receivers to find out.</li><li><strong>Saves fire them regardless of intent.</strong> Admin edits, management commands, scripts and the shell all trigger them. Fixtures loaded with loaddata do too; receivers get raw=True in that case, and most receivers never check it.</li><li><strong>Data migrations are inconsistent.</strong> RunPython code uses historical models from apps.get_model(), which aren’t the same class as your imported Order, so a receiver registered with sender=Order doesn’t fire there. One more way the same business event behaves differently depending on where the row was written.</li><li><strong>Bulk operations skip them.</strong> The <a href="https://docs.djangoproject.com/en/stable/ref/models/querysets/#update">QuerySet API reference</a> is explicit that update() and bulk_create() don’t call save() and don’t send pre_save or post_save; bulk_update() doesn’t either. QuerySet.delete(), on the other hand, does send pre_delete and post_delete for each object. Remembering which is which is a job nobody should have.</li><li><strong>post_save can run before the data is committed.</strong> The signal is sent from save(), so when that save happens inside a transaction (an atomic() block, ATOMIC_REQUESTS, or a service that wraps several writes), the receiver runs before the commit. A receiver that enqueues a Celery task can have the worker pick it up and read the old row, or act on a change that is then rolled back.</li><li><strong>Order is an undocumented dependency.</strong> Receivers run in the order they were connected, which in practice means app-loading and import order. When one receiver depends on another’s side effect, reordering INSTALLED_APPS can change behaviour.</li></ul><p>None of this is a bug in Django. Signals are a low-level notification mechanism. They simply aren’t a good home for business rules.</p><h3>A service function makes the use case explicit</h3><p>For anything that represents a business event (an order ships, a subscription is cancelled, an invoice is issued), we write a plain function that does the whole thing in one visible place:</p><pre># orders/services.py<br>from django.db import transaction<br>from django.utils import timezone</pre><pre>from notifications.tasks import send_shipping_email<br>from .models import Order, OrderEvent<br></pre><pre>class InvalidTransition(Exception):<br>    pass<br></pre><pre>def mark_order_shipped(order_id, tracking_number, *, actor):<br>    with transaction.atomic():<br>        order = Order.objects.select_for_update().get(pk=order_id)<br>        if order.status != Order.Status.PAID:<br>            raise InvalidTransition(f&quot;Cannot ship an order in status {order.status}&quot;)</pre><pre>        order.status = Order.Status.SHIPPED<br>        order.tracking_number = tracking_number<br>        order.shipped_at = timezone.now()<br>        order.save(update_fields=[&quot;status&quot;, &quot;tracking_number&quot;, &quot;shipped_at&quot;])</pre><pre>        OrderEvent.objects.create(order=order, kind=&quot;shipped&quot;, actor=actor)<br>        transaction.on_commit(lambda: send_shipping_email.delay(order.pk))</pre><pre>    return order</pre><p>Everything the use case does is visible from the function body: the state check, the update, the audit record and the email. A few details carry most of the weight:</p><ul><li><strong>The row lock.</strong> select_for_update() makes a second concurrent request wait until the first commits. It then sees the order as shipped, fails the status check and raises InvalidTransition. The duplicate admin save from the opening story would raise instead of sending a second email. select_for_update() has to run inside a transaction; Django raises TransactionManagementError if you evaluate it in autocommit mode.</li><li><strong>The transition, not the state.</strong> The email is tied to the move from paid to shipped, which can happen once, rather than to “saved while shipped”, which can happen any number of times.</li><li><strong>The email after commit.</strong> <a href="https://docs.djangoproject.com/en/stable/topics/db/transactions/#performing-actions-after-commit">transaction.on_commit()</a> runs the callback only once the transaction commits, and discards it if the transaction rolls back. The worker always sees the new state, and a failed transaction sends nothing.</li><li><strong>A primary key, not an object.</strong> The task receives order.pk and loads a fresh row, rather than a serialised copy of an instance that may be stale by the time the worker runs.</li></ul><p>If you’re on Celery 5.4 or later with its Django task class, send_shipping_email.delay_on_commit(order.pk) does the same wrapping for you. The <a href="https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html">Celery Django guide</a> covers it, including the trade-off that delay_on_commit() doesn’t return a task ID.</p><h3>Calling it from everywhere that ships orders</h3><p>Views, admin actions, API endpoints and management commands all call the same function, and handle the failure in the way that suits them:</p><pre># orders/views.py<br>from django.contrib import messages<br>from django.contrib.auth.decorators import permission_required<br>from django.shortcuts import redirect<br>from django.views.decorators.http import require_POST</pre><pre>from .forms import ShipOrderForm<br>from .services import InvalidTransition, mark_order_shipped<br></pre><pre>@require_POST<br>@permission_required(&quot;orders.change_order&quot;)<br>def ship_order(request, order_id):<br>    form = ShipOrderForm(request.POST)<br>    if form.is_valid():<br>        try:<br>            mark_order_shipped(<br>                order_id, form.cleaned_data[&quot;tracking_number&quot;], actor=request.user<br>            )<br>        except InvalidTransition as exc:<br>            messages.error(request, str(exc))<br>    return redirect(&quot;orders:detail&quot;, order_id)</pre><p>Two details are worth knowing. If you use ATOMIC_REQUESTS, the service’s atomic() block becomes a savepoint inside the request transaction, and Django runs on_commit callbacks when the outermost transaction commits, so the email still waits for the real commit. And if you call on_commit() when no transaction is open, Django runs the callback immediately. That’s why the service registers it inside its own atomic() block.</p><p>A data migration that bulk-updates historic orders doesn’t call the service, and therefore doesn’t email anyone, which is exactly what you want. If you ever do need the side effects for a backfill, you call the service in a loop and the decision is visible in the code.</p><h3>Make the side effect safe to repeat</h3><p>Moving the trigger into a service fixes “sent when it shouldn’t be”. It doesn’t make the task itself safe to retry. Celery tasks can run more than once, for example when a worker retries after a temporary mail server error. A small guard on the task keeps retries from turning into duplicates:</p><pre># notifications/tasks.py<br>from smtplib import SMTPException</pre><pre>from celery import shared_task<br>from django.core.mail import send_mail<br>from django.utils import timezone</pre><pre>from orders.models import Order<br></pre><pre>@shared_task(autoretry_for=(SMTPException,), retry_backoff=True, max_retries=5)<br>def send_shipping_email(order_id):<br>    order = Order.objects.select_related(&quot;customer&quot;).get(pk=order_id)<br>    if order.shipping_email_sent_at is not None:<br>        return</pre><pre>    send_mail(<br>        subject=&quot;Your order has shipped&quot;,<br>        message=f&quot;Tracking number: {order.tracking_number}&quot;,<br>        from_email=None,<br>        recipient_list=[order.customer.email],<br>    )<br>    Order.objects.filter(pk=order_id, shipping_email_sent_at__isnull=True).update(<br>        shipping_email_sent_at=timezone.now()<br>    )</pre><p>This doesn’t give you exactly-once delivery. A crash between sending and recording can still lead to a second email. But it turns duplicates from a routine event into a rare one, and the timestamp gives support a straight answer to “did we email this customer?”.</p><h3>Testing gets simpler too</h3><p>A service function is tested like any other function: call it and assert on the result. There’s no need to disconnect receivers in tests or work out which signals fired:</p><pre>@pytest.mark.django_db<br>def test_shipping_queues_one_email(paid_order, staff_user, django_capture_on_commit_callbacks, mocker):<br>    send = mocker.patch(&quot;orders.services.send_shipping_email.delay&quot;)</pre><pre>    with django_capture_on_commit_callbacks(execute=True):<br>        mark_order_shipped(paid_order.pk, &quot;TRK123&quot;, actor=staff_user)</pre><pre>    send.assert_called_once_with(paid_order.pk)</pre><p>A normal django_db test runs inside a transaction that is rolled back, so on_commit callbacks would never fire on their own. pytest-django’s <a href="https://pytest-django.readthedocs.io/en/latest/helpers.html#django-capture-on-commit-callbacks">django_capture_on_commit_callbacks</a> fixture captures them, and with execute=True runs them as the block exits, emulating a commit. (mocker comes from the pytest-mock plugin.)</p><p>The failure path is just as direct, and it’s the test that would have caught the duplicate emails:</p><pre>@pytest.mark.django_db<br>def test_shipping_twice_is_rejected(paid_order, staff_user, django_capture_on_commit_callbacks, mocker):<br>    send = mocker.patch(&quot;orders.services.send_shipping_email.delay&quot;)</pre><pre>    with django_capture_on_commit_callbacks(execute=True):<br>        mark_order_shipped(paid_order.pk, &quot;TRK123&quot;, actor=staff_user)</pre><pre>    with pytest.raises(InvalidTransition):<br>        mark_order_shipped(paid_order.pk, &quot;TRK123&quot;, actor=staff_user)</pre><pre>    assert send.call_count == 1</pre><h3>Where signals are still the right tool</h3><p>Signals remain useful when the code that sends them can’t know about the code that receives them:</p><ul><li>Reacting to events from Django itself or third-party apps, such as user_logged_in, request_finished or a payments package’s webhook signal. You can’t edit that code, so a signal is the intended extension point.</li><li>Cross-cutting technical concerns that should apply to every save, such as cache invalidation or search indexing, as long as they are idempotent and tolerate the gaps around bulk updates.</li><li>Letting an optional app hook into a core app without the core app importing it.</li></ul><p>When you do keep a receiver, a few habits prevent most surprises. Connect it in the app’s AppConfig.ready(), and pass dispatch_uid if the connecting code could run more than once. Return early when raw is true so fixture loading doesn’t trigger it. And wrap anything that talks to the outside world in transaction.on_commit().</p><p>The distinction is between technical plumbing, which signals handle well, and business behaviour, which should be called explicitly.</p><h3>Migrating away from existing receivers</h3><p>You don’t need to remove every signal in one go. Start with the receivers that cause incidents:</p><ol><li><strong>Make an inventory.</strong> grep -rn &quot;@receiver\|\.connect(&quot; --include=&quot;*.py&quot; . usually finds them all. For each, note the signal, the sender and what it does.</li><li><strong>Sort them.</strong> Mark each one as business rule (emails, payments, stock, state changes) or technical plumbing (cache, search index, metrics).</li><li><strong>Find every trigger.</strong> For each business receiver, list the places that save that model: views, admin, API, tasks, commands. Those are the call sites for the new service function.</li><li><strong>Move one receiver per change.</strong> Write the service function, call it from the places that should trigger the behaviour, and delete the receiver in the same change. Shipping both at once is how the duplicate emails come back.</li><li><strong>Check the bulk paths.</strong> Look for update() and bulk_create() on the same model. They never fired the receiver, so decide explicitly whether they should call the service now.</li><li><strong>Keep the plumbing.</strong> Leave the remaining technical receivers in place, but make sure they check raw and defer external calls with on_commit().</li></ol><h3>A quick checklist</h3><ul><li>Every business event has one service function, and every entry point calls it</li><li>State changes are checked as transitions, under select_for_update() inside atomic()</li><li>External side effects are queued with on_commit() (or delay_on_commit())</li><li>Tasks receive primary keys and are safe to run twice</li><li>Tests use django_capture_on_commit_callbacks and cover the failure path</li><li>Remaining receivers are technical, idempotent, check raw and live in ready()</li></ul><h3>Conclusion</h3><p>Signals hide behaviour from the code that triggers it, fire for saves regardless of intent, skip bulk writes, and can run before the data is committed. For business events, a service function that does the work explicitly, inside a transaction, with side effects queued after commit, is easier to read, test and change. Keep signals for framework hooks and genuinely cross-cutting plumbing.</p><p>Lycore builds and maintains production systems on Django, React, Flutter and .NET. Get in touch at lycore.com if your Django codebase has started doing things nobody can explain.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3d2b3d19a633" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Python 3.15 Is Out: A Practical Upgrade Playbook for Django and AI Backend Teams]]></title>
            <link>https://medium.com/@lycore/python-3-15-is-out-a-practical-upgrade-playbook-for-django-and-ai-backend-teams-0b54b8ab9557?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/0b54b8ab9557</guid>
            <category><![CDATA[python]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[django]]></category>
            <category><![CDATA[backend-development]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 07:01:02 GMT</pubDate>
            <atom:updated>2026-11-02T07:01:02.036Z</atom:updated>
            <content:encoded><![CDATA[<p>Lazy imports, frozendict, a production-safe sampling profiler, and the one default change that can quietly break your file handling. Here is what to test, what to adopt, and what to leave alone for now.</p><p><a href="https://www.python.org/downloads/release/python-3150/">Python 3.15.0</a> shipped on 9 October 2026. Two weeks later, on 25 October, the core team pushed out <a href="https://www.python.org/downloads/release/python-3152/">3.15.2</a>. It was an expedited security release that blocks the compromised eMudhra intermediate CA keys in the ssl module and fixes a tarfile path-handling issue. If your team runs Django apps, Celery workers, or Python services that call LLM APIs, now is the time to plan the upgrade instead of letting it slip into next year.</p><p>We have read through the <a href="https://docs.python.org/3/whatsnew/3.15.html">official “What’s new in Python 3.15” document</a> and ran the examples below on a 3.15 interpreter. This post skips the full changelog. It covers what matters for teams shipping web and AI backends: what can break, which new features are worth adopting, and the order we would roll it out in.</p><p>Don’t standardize on 3.15.0. Target the latest 3.15 patch release, which is 3.15.2 at the time of writing. Python 3.15.1 (October 21) was the first maintenance release, with around 330 bug fixes, and an expedited security release: its bundled pip includes an updated certifi to deal with the eMudhra / emSign CA certificate incident. Four days later, <a href="https://www.python.org/downloads/release/python-3152/">Python 3.15.2</a> followed with around 130 more fixes and made the ssl module itself block the compromised eMudhra intermediate CA keys (CVE-2026–115557), along with a tarfile filter fix (CVE-2026–114583). Pin your Docker images and CI to 3.15.2 or newer. In existing virtual environments, upgrade pip (and uv, if you use it), because those environments don’t pick up the new bundled pip on their own.</p><h3>Why this release matters more than usual</h3><p>Most Python minor releases are easy to adopt. You bump the version, run the tests, fix a deprecation or two, and move on. Python 3.15 is mostly like that, with three exceptions that make it worth a deliberate plan:</p><ol><li><strong>A default changed.</strong> Python now uses UTF-8 as the default text encoding, whatever the system locale says (PEP 686).</li><li><strong>Language syntax changed.</strong> There is a new lazy soft keyword for imports (PEP 810), plus unpacking inside comprehensions (PEP 798).</li><li><strong>Production tooling got much better.</strong> A new statistical sampling profiler called Tachyon ships in the standard library as profiling.sampling. It can attach to a running process by PID.</li></ol><p>There is also a timing detail for Django teams. According to the <a href="https://docs.djangoproject.com/en/6.1/faq/install/">Django installation FAQ</a>, Python 3.15 support arrives in Django 6.1.3. Django 6.0 supports Python 3.12 to 3.14 only. The <a href="https://docs.djangoproject.com/en/6.1/releases/6.1.3/">Django 6.1.3 release notes</a> list it as expected on 3 November 2026, with “compatibility with Python 3.15”. So the order is: get on Django 6.1 first, then move the interpreter.</p><h3>Part 1: What can break</h3><h3>The UTF-8 default</h3><p>This is the change most likely to bite, and it does so quietly. Any open() call without an encoding argument now uses UTF-8 instead of the locale&#39;s preferred encoding:</p><pre># Before 3.15: encoding depended on the OS locale (often cp1252 on Windows)<br># From 3.15: always UTF-8 unless you say otherwise<br>with open(&quot;export.csv&quot;) as f:<br>    rows = f.read()</pre><p>On Linux containers with a UTF-8 locale, you probably won’t notice anything. The risk sits in places like these:</p><ul><li>Windows developer machines or Windows-hosted jobs that read files produced by legacy tools</li><li>CSV exports that customers open in Excel and send back to you</li><li>Old fixtures or data files that were saved in a regional encoding years ago</li></ul><p>The fix is the same advice that has applied for years: always pass encoding explicitly. To find the calls that rely on the default, turn on the opt-in encoding warning, which has existed since Python 3.10:</p><pre>python -X warn_default_encoding -m pytest<br># or<br>PYTHONWARNDEFAULTENCODING=1 python -m pytest</pre><p>On 3.15, every implicit-encoding open() emits an EncodingWarning that points to the exact line. If you need the old behaviour as a stopgap, the docs say you can turn UTF-8 mode off with PYTHONUTF8=0 or -X utf8=0. Treat that as a temporary bridge, not a fix.</p><h3>Smaller API changes worth grepping for</h3><p>The “Porting to Python 3.15” section is short, but a few items show up in real codebases:</p><ul><li><strong>sqlite3.connect()</strong>: every parameter except database is now keyword-only. sqlite3.connect(&quot;db.sqlite3&quot;, 5.0) now raises TypeError. Write timeout=5.0 instead. Scripts and test helpers often use the positional form.</li><li><strong>argparse</strong>: with add_argument(&#39;-f&#39;, &#39;-foo&#39;), dest is now inferred from the single-dash long option, so you get foo instead of f. Pass dest explicitly if any code reads args.f.</li><li><strong>base64.urlsafe_b64decode()</strong> no longer requires padding. If you relied on it rejecting unpadded input (for example, as part of validating tokens), pass padded=True.</li><li><strong>unittest</strong>: assertWarns() and assertWarnsRegex() no longer swallow warnings that don&#39;t match. Your test output may get noisier, and suites that treat warnings as errors may start failing.</li><li><strong>profile module</strong>: deprecated and scheduled for removal in 3.17. cProfile stays as an alias for the new profiling.tracing.</li></ul><p>None of these is hard to fix. They are just easier to fix before a deploy than during one.</p><h3>Dependencies are the real gate</h3><p>Your own code is rarely what blocks a Python upgrade. Your dependencies are. Anything with compiled extensions, such as database drivers, crypto libraries, numeric and ML stacks, and some tokenizer packages, needs wheels built for 3.15. Before you schedule production work, check that each critical package has published 3.15 wheels or at least installs cleanly from source in your build image.</p><h3>Part 2: Features worth adopting</h3><h3>Lazy imports: faster startup without scattering imports</h3><p>AI-heavy Python services often have a startup problem. Importing an LLM SDK, a vector store client, pandas, and a few cloud SDKs at module level can make every management command, every worker boot, and every test run pay that cost, even when the code path never uses them.</p><p>The usual workaround was to move imports inside functions. It works, but imports end up scattered across the codebase. Python 3.15 adds an explicit alternative:</p><pre># services/summarize.py<br>lazy import json<br>lazy from openai import OpenAI      # not loaded until first use</pre><pre>def summarize(text: str) -&gt; str:<br>    client = OpenAI()                # the module actually loads here<br>    ...</pre><p>We checked the behaviour on 3.15: after lazy import json, &quot;json&quot; in sys.modules is False until the first attribute access, and True right after it.</p><p>Know the rules before you sprinkle lazy around:</p><ul><li>It is only allowed at module scope. Using it inside a function, a class body, or a try/except block is a SyntaxError.</li><li>lazy from x import * and lazy __future__ imports are not allowed.</li><li>If the module fails to load, the error is raised at <strong>first use</strong>, not at import time. The traceback shows both locations, but in a web app this can move a startup crash into the middle of a request. For anything critical, a smoke test that touches the lazy names at boot is a cheap safeguard.</li></ul><p>If you need to support older Pythons, list the module names in __lazy_modules__. On 3.15, regular imports of those modules become lazy, and older versions simply ignore the variable:</p><pre>__lazy_modules__ = [&quot;pandas&quot;, &quot;openai&quot;]<br>import pandas   # lazy on 3.15+, eager on older versions<br>import openai</pre><p>There is also a global mode (-X lazy_imports=all or PYTHON_LAZY_IMPORTS=all) and a filter hook, sys.set_lazy_imports_filter(). The What&#39;s New document shows the filter being used to make only your own application&#39;s modules lazy while third-party packages stay eager. That is a sensible middle ground for a large Django project.</p><p>Measure before and after. python -X importtime manage.py check already shows where import time goes, so you can confirm whether lazy imports actually help your startup path rather than guessing.</p><h3>frozendict: immutable config you can hash</h3><p>frozendict is now a built-in. It is immutable, preserves insertion order, ignores order when comparing, and is hashable when its keys and values are hashable:</p><pre>MODEL_DEFAULTS = frozendict(model=&quot;small&quot;, temperature=0.2, max_tokens=800)</pre><pre>MODEL_DEFAULTS[&quot;temperature&quot;] = 0.9<br># TypeError: &#39;frozendict&#39; object does not support item assignment</pre><pre># Hashable, so it can be a cache key<br>cache = {MODEL_DEFAULTS: &quot;cached-response&quot;}</pre><p>That fits module-level settings, feature-flag snapshots, and prompt or model parameter sets that should never change at runtime. json.dumps() handles it directly, which we confirmed. One thing to watch: frozendict is <strong>not</strong> a subclass of dict, so isinstance(x, dict) returns False. If you have validation code that checks for dict, update it to isinstance(x, (dict, frozendict)) or check against collections.abc.Mapping.</p><h3>sentinel: a better &quot;missing&quot; value</h3><p>Many codebases have something like _MISSING = object() to tell &quot;not passed&quot; apart from None. The new built-in sentinel does the same job with a readable repr, identity that survives copying and pickling (when defined at module or class scope), and support in type hints:</p><pre>MISSING = sentinel(&quot;MISSING&quot;)</pre><pre>def update_profile(user_id: int, bio: str | None | MISSING = MISSING):<br>    if bio is MISSING:<br>        return            # field not sent: leave it alone<br>    save_bio(user_id, bio)  # None means &quot;clear it&quot;</pre><p>PATCH-style API endpoints, where “absent” and “explicitly null” mean different things, are exactly where this helps.</p><h3>Unpacking in comprehensions</h3><p>A small but welcome change: * and ** now work inside comprehensions.</p><pre>batches = [[1, 2], [3], [4, 5]]<br>flat = [*b for b in batches]            # [1, 2, 3, 4, 5]</pre><pre>layers = [{&quot;model&quot;: &quot;small&quot;, &quot;temperature&quot;: 0.2}, {&quot;temperature&quot;: 0.7}]<br>merged = {**d for d in layers}          # later dicts win<br># {&#39;model&#39;: &#39;small&#39;, &#39;temperature&#39;: 0.7}</pre><p>It reads better than nested comprehensions or itertools.chain.from_iterable() for merging config layers or flattening batched results.</p><h3>Part 3: Tachyon, the profiler you can use in production</h3><p>For teams running real traffic, this may be the most useful part of the release. profiling.sampling (Tachyon) samples stack traces from a running process instead of instrumenting every call. The docs describe it as low overhead and suited to debugging in production, with no code changes or restarts.</p><pre># Profile a running Gunicorn or Celery worker for 30 seconds into a flame graph<br>python -m profiling.sampling attach --flamegraph -d 30 -o profile.html 12345</pre><pre># One-shot stack dump of a stuck process (like a traceback for every thread)<br>python -m profiling.sampling dump 12345</pre><pre># Live, top-like view<br>python -m profiling.sampling attach --live 12345</pre><p>Three features stand out for web and AI workloads:</p><ul><li><strong>Modes.</strong> --mode wall (the default) includes time spent waiting on I/O. --mode cpu counts only on-CPU time. For a slow endpoint that calls an LLM, wall mode shows you the network wait, while CPU mode shows whether your own parsing or serialization is the real problem.</li><li><strong>--async-aware</strong> rebuilds stacks across await boundaries. That matters a lot for async Django views and asyncio-based agent loops.</li><li><strong>dump</strong> answers the 2 a.m. question: what is this hung worker doing right now?</li></ul><p>Two practical caveats from the <a href="https://docs.python.org/3/library/profiling.sampling.html">profiling.sampling documentation</a>. First, attaching to another process needs elevated permissions: root, CAP_SYS_PTRACE, or a relaxed Yama ptrace_scope on Linux. In containers, that usually means a deliberate debug setup instead of something you can just run. Second, the profiler and the target must run the <strong>same Python minor version</strong>, so you can&#39;t attach from 3.14 to a 3.15 process. Plan your debug sidecar or image to match.</p><h3>Part 4: The JIT, with realistic expectations</h3><p>The experimental JIT got a big upgrade in 3.15. The release notes report a 7–8% geometric mean improvement over the standard interpreter on x86–64 Linux, and 11–12% over the tail-calling interpreter on AArch64 macOS, measured with pyperformance. The same section notes that individual benchmarks range from roughly a 15% slowdown to more than a 100% speedup.</p><p>That range is the key point. A geometric mean across a benchmark suite tells you little about a Django app that spends most of its time waiting on PostgreSQL and an LLM API. The JIT is still experimental. Update: the JIT’s future in CPython is uncertain. <a href="https://www.infoworld.com/article/4241003/python-jettisons-the-native-jit-at-least-for-now.html">According to InfoWorld</a>, the Python Steering Council had paused JIT work until a supported-JIT proposal was approved, and on October 19 the authors withdrew PEP 836, so the experimental JIT is being removed from the mainline interpreter. Treat it as a curiosity, not a foundation for production plans. Whether it is available depends on how your interpreter was built (--enable-experimental-jit), and on builds that include it, PYTHON_JIT=1 or PYTHON_JIT=0 controls it at startup. Our advice: leave it off in production and try it on a CPU-heavy batch job or a benchmark of your own hot path. Ship it only if your numbers, not the release notes, justify it.</p><h3>Our upgrade order</h3><p>Here is the sequence we would follow for a typical Django plus Celery plus AI-integration codebase:</p><ol><li><strong>Get green on 3.14 with warnings as errors.</strong> Run python -W error::DeprecationWarning -m pytest. Anything deprecated earlier and removed in 3.15 shows up here first.</li><li><strong>Fix implicit encodings.</strong> Run the suite with -X warn_default_encoding and add encoding=&quot;utf-8&quot; (or the correct legacy encoding) wherever it is flagged.</li><li><strong>Upgrade Django to 6.1.x.</strong> Django’s 3.15 support lands in 6.1.3, so this step comes before the interpreter change.</li><li><strong>Add 3.15 to CI as a non-blocking job.</strong> Watch for wheel availability, the sqlite3 and argparse changes, and new test warnings. Make it blocking once it stays green.</li><li><strong>Use the latest patch release.</strong> Start from 3.15.2 or later, since it contains the security fixes and a fix for an import deadlock regression introduced in 3.15.0.</li><li><strong>Roll out by workload.</strong> Start with background workers and internal tools, then the web tier. Keep the previous image available for a quick rollback.</li><li><strong>Adopt new features one at a time.</strong> frozendict for config, sentinel for PATCH semantics, and lazy imports where -X importtime shows a real startup cost. Avoid a big-bang refactor.</li></ol><p>If you are also reviewing your deploy pipeline as part of this work, we covered the wider picture of <a href="https://www.lycore.com/blog/production-ready-django-app-deployment-scaling/">deploying and scaling a production Django app</a> separately. Most of that checklist applies here too.</p><h3>Conclusion</h3><p>Python 3.15 is a solid release with a few sharp edges. The UTF-8 default is the change most likely to cause a quiet production bug, and you can find every affected line today with one interpreter flag. Lazy imports, frozendict, and sentinel are useful additions that don&#39;t require a big rewrite. Tachyon gives Python teams a sampling profiler in the standard library that can attach to a live process, which is valuable for anyone debugging slow AI endpoints or stuck workers.</p><p>Don’t rush it, and don’t put it off. Start the CI job this week, wait for Django 6.1.3 and your dependencies to catch up, and roll out in stages. The release notes describe the changes, but only your test suite can tell you how they affect your code.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0b54b8ab9557" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Altman Says Your RAM Is Wasted. Here Is the Case for Local Inference Anyway]]></title>
            <link>https://medium.com/@lycore/altman-says-your-ram-is-wasted-here-is-the-case-for-local-inference-anyway-8cc7c709832b?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/8cc7c709832b</guid>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[technology]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Sat, 31 Oct 2026 06:42:36 GMT</pubDate>
            <atom:updated>2026-10-31T06:42:36.923Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UCSemzxmbwhiAv82wMrX1g.jpeg" /></figure><p>Sam Altman has a point about RAM. He also has a business that benefits from you agreeing with him. Both things can be true, and if you build software for a living, the useful question is not who wins the argument online. It is where your own inference workloads should run, given a memory market that is tight and AI vendors whose futures are still being written.</p><h3>What Altman actually said</h3><p>On the Cheeky Pint podcast with Stripe cofounder John Collison, Altman was asked about supply constraints. As quoted by <a href="https://www.theverge.com/ai-artificial-intelligence/1023820/sam-altman-ram-shortage-cloud-servers">The Verge</a> and <a href="https://www.tomshardware.com/tech-industry/data-centers/openai-ceo-says-its-quite-terrible-for-your-pc-to-have-ram-sam-altman-says-data-centers-offer-better-utilization">Tom’s Hardware</a>, he first described the hope many people have for local inference: that open-source models running on their own computers mean “we’re not going to need all the stuff in the data center.” Then he said:</p><blockquote><em>“From a global allocation perspective, actually, it’s quite terrible to have everybody have a bunch of the world’s limited supply of DRAM sitting in their own computer and being used 5% of the time. You want maximum utilization in the cloud if you’re trying to deal with this very supply-constrained environment. And somehow many people’s intuition is the opposite of that.”</em></blockquote><p>Note what he did not say. He did not say PCs are terrible, or that you should not own a computer. His claim is about allocation: a scarce resource sitting idle most of the time is wasteful compared with the same resource kept busy in a shared data center. As an economic argument about utilization, that is hard to dispute.</p><h3>The context: memory is expensive right now</h3><p>The comment landed during a painful stretch for anyone buying memory. <a href="https://www.tweaktown.com/news/114419/sam-altman-says-your-pcs-ram-is-a-quite-terrible-use-of-the-worlds-limited-dram-supply/index.html">TweakTown reports</a> that the cheapest 32GB DDR5–6000 kit it tracked had climbed to $449, that a Corsair 32GB DDR5–4800 kit that once sold for $80 now costs $399, and that TrendForce forecasts conventional DRAM contract prices to rise another 10% to 15% in the fourth quarter of 2026 as suppliers prioritize AI server memory. The Verge adds that Samsung and SK Hynix have long-term deals with cloud operators and projects like OpenAI’s Stargate that have reserved much of the world’s RAM output over the next few years.</p><p>So the person arguing that DRAM belongs in the data center runs one of the companies whose demand is helping make DRAM scarce. That does not make the argument wrong. It does mean you should check it against your own numbers rather than take it on authority.</p><h3>A counterpoint from Elon Musk</h3><p>Musk took the memory debate in a different direction. In a reply on X reported by <a href="https://www.pcgamer.com/hardware/memory/elon-musk-says-robot-ram-needs-will-exceed-everything-else-on-earth-but-only-assuming-we-throw-all-the-data-centers-into-space/">PC Gamer</a>, defending Counterpoint research on robot memory demand, he wrote:</p><blockquote><em>“Robot RAM needs will exceed everything else on Earth, as you need the robots to be on physical substates in order to shape atoms. Super intelligence needs will vastly exceed that, but ~100% of SI will be deep space compute. Obviously.”</em></blockquote><p>PC Gamer points out the catch in its own headline: the claim only works if the data centers move to space. Still, the underlying idea is relevant here. Some compute has to sit next to the physical world, close to sensors and actuators, because the round trip to a distant data center is too slow or too fragile. That is an argument for local inference, just at the robot end of the spectrum rather than the laptop end.</p><h3>A note on vendor risk</h3><p><em>Short context paragraph, not investment advice.</em> On October 30, <a href="https://techcrunch.com/2026/10/30/anthropic-expected-to-receive-high-junk-rating-from-credit-agencies-following-ipo-report-says/">TechCrunch reported</a>, citing the Financial Times, that Blackstone told investors in a chip financing deal that it now expects Anthropic to receive a double-B range rating, the top tier of speculative “junk” grade, after its IPO. That is a forecast by Blackstone, not a rating issued by any credit agency, and both Anthropic and Blackstone declined to comment to TechCrunch. We draw no conclusion about Anthropic from it. The practical lesson is general: even the strongest AI vendors are businesses with large capital needs, changing prices and changing terms. If your product depends on one hosted model, you are carrying that vendor’s risk whether or not you think about it.</p><h3>Opinion: the case for local inference anyway</h3><p><em>Everything from here on is our opinion, based on the reasoning laid out below.</em></p><p>Altman is right that idle memory is wasteful at a global level. But engineering teams do not optimize global DRAM allocation. They optimize for their users, their data and their risk. On those terms, local or on-prem inference is often the better choice, and in other cases renting cloud capacity clearly wins. The trick is knowing which case you are in.</p><h3>When local or on-prem inference makes sense</h3><ul><li><strong>Privacy and data residency.</strong> If prompts contain health records, legal documents, source code or customer data you are not allowed to send to a third party, keeping the model inside your boundary removes a whole category of risk and paperwork.</li><li><strong>Latency and offline use.</strong> Autocomplete, on-device assistants, field apps and anything on a factory floor benefit from not waiting on a network. This is the same logic as Musk’s robots, at a smaller scale.</li><li><strong>Steady, predictable utilization.</strong> Altman’s own argument cuts both ways. Hardware idling at 5% is wasteful, but hardware you keep busy around the clock with a steady internal workload, like classification, embedding or document extraction, is not idle. High utilization is the point of owning it.</li><li><strong>Vendor risk.</strong> An open-weight model you run yourself cannot be deprecated, repriced or rate-limited out from under you. It can be a fallback even if it is not your primary path.</li><li><strong>Small, specialized models.</strong> Many production tasks do not need a frontier model. A smaller model tuned for one job may run comfortably on modest hardware.</li></ul><h3>When renting cloud capacity is better</h3><ul><li><strong>Spiky or unpredictable demand.</strong> If traffic swings wildly, paying per request is usually more sensible than buying for the peak and idling the rest of the time. This is exactly the utilization argument Altman made, and it is correct here.</li><li><strong>You need frontier capability.</strong> The largest hosted models are not something most teams can run themselves, and for hard reasoning or broad knowledge tasks the quality gap can matter more than cost.</li><li><strong>Early-stage products.</strong> Before you know your usage pattern, committing to hardware is guessing. Rent first, measure, then decide.</li><li><strong>No one to run it.</strong> Self-hosted inference is infrastructure. It needs patching, monitoring, capacity planning and someone on call. If you do not have that, the cloud is buying you an operations team.</li></ul><h3>A simple decision framework</h3><p>For each AI workload, not for the company as a whole, answer five questions in order:</p><ol><li><strong>Can this data leave our boundary?</strong> If no, the answer is local or on-prem, or a hosted option with contractual and technical controls you have actually verified. Stop here.</li><li><strong>Does the task need a frontier model?</strong> Test a smaller open-weight model against real examples. If it fails your quality bar, rent the frontier model and revisit later.</li><li><strong>Is the load steady or spiky?</strong> Steady and high favors owning. Spiky and low favors renting.</li><li><strong>Is latency or offline operation a product requirement?</strong> If yes, push inference toward the edge.</li><li><strong>What happens if our vendor changes price, terms or availability next quarter?</strong> If the honest answer is “the product stops,” you need a second path, either another vendor or a local fallback.</li></ol><p>Most teams will end up with a mix: a hosted frontier model for the hardest requests, a cheaper hosted or local model for routine ones, and an abstraction layer in the code so swapping between them is a configuration change rather than a rewrite.</p><h3>A cost-thinking checklist</h3><p>We are deliberately not quoting prices here, because they are moving fast and depend heavily on your volumes and region. Instead, make sure your comparison includes all of these:</p><ul><li><strong>Utilization.</strong> Estimate how many hours a day the hardware would actually be busy. Be honest. This is the number Altman’s argument rests on.</li><li><strong>Full hardware cost.</strong> Not just GPUs, but memory, which TweakTown’s figures show is not cheap right now, plus power, cooling, hosting and replacement cycles.</li><li><strong>People cost.</strong> Engineering time to deploy, monitor, update models and handle incidents.</li><li><strong>Per-request cloud cost at realistic volume.</strong> Include input and output tokens, retries, long contexts and any premium for higher rate limits.</li><li><strong>Quality cost.</strong> A cheaper model that needs more retries, more human review or produces more support tickets is not cheaper.</li><li><strong>Compliance cost.</strong> Reviews, contracts and audits for sending data to a third party, versus the controls required to run models yourself.</li><li><strong>Switching cost.</strong> How much code assumes one vendor’s API, prompt format or tool-calling style.</li><li><strong>Risk cost.</strong> What an outage, price change or deprecation would cost you, multiplied by how likely you think it is.</li></ul><h3>What we would do this quarter</h3><p>Put a thin provider interface between your application and every model call. Keep at least one open-weight model running somewhere, even at small scale, and run your evaluation set against it regularly so you know what it can and cannot do. Log usage per workload so the “steady or spiky” question has data behind it. Then revisit the decision every quarter, because memory prices, model quality and vendor terms are all moving.</p><p>If you are planning this kind of setup, it is the sort of work our <a href="https://www.lycore.com/ai-development-services/">AI development team</a> does with clients, including projects where data cannot leave their infrastructure.</p><h3>The bottom line</h3><p>Altman is right that a world where everyone’s RAM sits idle is inefficient. But your architecture is not a global allocation problem. It is a set of specific workloads with specific privacy, latency, quality and risk requirements. For some of them, renting the busiest, biggest model in someone else’s data center is the right call. For others, the memory sitting on your own hardware, kept busy, is not wasted at all. It is insurance.</p><h3>Sources</h3><ul><li><a href="https://www.theverge.com/ai-artificial-intelligence/1023820/sam-altman-ram-shortage-cloud-servers">The Verge: Sam Altman says it’s better for RAM to be in a data center than on your desk</a> (Oct 30, 2026)</li><li><a href="https://www.tomshardware.com/tech-industry/data-centers/openai-ceo-says-its-quite-terrible-for-your-pc-to-have-ram-sam-altman-says-data-centers-offer-better-utilization">Tom’s Hardware on Altman’s Cheeky Pint comments</a> (Oct 30, 2026)</li><li><a href="https://www.tweaktown.com/news/114419/sam-altman-says-your-pcs-ram-is-a-quite-terrible-use-of-the-worlds-limited-dram-supply/index.html">TweakTown on memory prices and the TrendForce forecast</a> (Oct 30, 2026)</li><li><a href="https://www.pcgamer.com/hardware/memory/elon-musk-says-robot-ram-needs-will-exceed-everything-else-on-earth-but-only-assuming-we-throw-all-the-data-centers-into-space/">PC Gamer on Elon Musk’s robot RAM comment</a> (Oct 28, 2026)</li><li><a href="https://techcrunch.com/2026/10/30/anthropic-expected-to-receive-high-junk-rating-from-credit-agencies-following-ipo-report-says/">TechCrunch on the FT report about Blackstone’s rating expectation</a> (Oct 30, 2026)</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8cc7c709832b" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your IDE Is Production Now: What the Backdoored Claude Code Extension Teaches Every Dev Team]]></title>
            <link>https://medium.com/@lycore/your-ide-is-production-now-what-the-backdoored-claude-code-extension-teaches-every-dev-team-13382771dc7f?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/13382771dc7f</guid>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[programming]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Sat, 31 Oct 2026 06:40:30 GMT</pubDate>
            <atom:updated>2026-10-31T06:40:30.365Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ITOl6wpG-FD3i_bqVMPJHw.jpeg" /></figure><p>For about eleven hours on October 29, developers who installed or auto-updated the Claude Code extension from the Cursor VS Code Extension Marketplace on Windows or macOS may have received something extra: a backdoor that tried to give a stranger SSH access to their machine. Anthropic did not build it. Anthropic’s own systems were not compromised. But the incident is a clean, well-documented example of a risk most teams still underrate: the code editor is now one of the most privileged pieces of software in the company, and it installs third-party code with very little friction.</p><p>This post walks through what is confirmed, what the malicious code did, how to check your own machines, and the controls we think every engineering team should have in place before the next one.</p><h3>What happened: the timeline</h3><p>According to <a href="https://cursor.com/blog/claude-code-extension-security-notice">Cursor’s security notice</a>, maliciously modified builds of the extension Anthropic.claude-code, versions 2.1.312 and 2.1.313 for Windows and macOS, were available in the Cursor VS Code Extension Marketplace between about 2:27 AM PT and 1:35 PM PT on October 29, 2026. Cursor states plainly that these versions &quot;were not developed by Anthropic.&quot;</p><p>Cursor says it removed the malicious version as soon as it was detected, then shut down the whole marketplace “out of an abundance of caution,” audited access, confirmed no other extensions were tampered with, rotated its keys, rebuilt the marketplace and brought it back online. It also worked with the hosting provider to take the attacker’s server offline. Cursor updated the notice on October 30 to refine the indicators and clarify the affected versions.</p><p>On October 30, <a href="https://www.microsoft.com/en-us/security/blog/2026/10/30/inside-malicious-claude-code-branded-cursor-extension-persistent-ssh-backdoor/">Microsoft Security Research published its own analysis</a>. Microsoft says it confirmed the malicious packages were never published or distributed from the Visual Studio Marketplace or from npm, that Anthropic confirmed to Microsoft its systems were not compromised, and that “all evidence suggests that the incident is limited to Cursor marketplace.”</p><p>The endpoint security vendor Phorion published <a href="https://phorion.io/blog/offical-claude-code-vsx-compromise/">a detailed technical advisory</a> focused mostly on macOS. Phorion’s write-up is a single vendor’s analysis, so we cite it for the technical detail it adds rather than for any broader claims. Phorion itself updated its advisory on October 31 to match Cursor’s list of affected versions.</p><p>What has not been published by these sources: who did it, and how the malicious build got into the marketplace. We are not going to guess.</p><h3>What the malicious code did</h3><p>All three sources describe the same basic chain. Microsoft notes that the sequence “reflects code execution order; it does not prove that every step succeeded on every host.” With that caveat, the injected code attempted to:</p><ol><li><strong>Collect host details.</strong> Cursor lists hostname, username, OS, architecture, home directory path, environment variable names, working directories, configured Git email and public IP. Microsoft and Phorion both stress that it collected the <em>names</em> of environment variables matching patterns like KEY, TOKEN, SECRET, PASS, AWS or GCP, up to forty of them, not their values. In Phorion’s words, it gives the operator “a quick inventory” of which credentials live on the machine.</li><li><strong>Plant an SSH key.</strong> It appended an attacker-controlled public key, labeled svc-telemetry, to the user&#39;s ~/.ssh/authorized_keys.</li><li><strong>Try to turn on SSH and open a reverse tunnel.</strong> It attempted to enable the local SSH service and open an outbound reverse SSH tunnel to the attacker’s server, so the attacker could reach the machine’s SSH port from outside.</li><li><strong>Install persistence.</strong> On macOS, a user LaunchAgent labeled com.apple.sysmond. On Windows, a scheduled task named SysMon that tries to run as SYSTEM, with a fallback script in the user&#39;s Startup folder. Background scripts retry the tunnel roughly every 60 seconds, according to Cursor.</li><li><strong>Beacon home.</strong> Send the collected host details to the attacker’s server.</li></ol><p>The names are worth noticing. svc-telemetry, SysMon and com.apple.sysmond are all chosen to look like boring system or monitoring components. Microsoft also notes that the loader &quot;suppresses command output and silently catches errors&quot; while the extension keeps working normally, so users had no visible sign anything was wrong.</p><h3>The part that partly failed</h3><p>Cursor says that on most macOS and Windows hosts the step that turns on the local SSH service fails, because it needs admin rights the extension does not have, and because of errors in the malicious code. Phorion documents one of those bugs in detail: on macOS the script checks the Remote Login state without elevation, gets an “administrator access” error instead of “Off,” and so never even attempts to enable SSH.</p><p>That is good news, but it is not the end of the story. Cursor is explicit: “The extension added the attacker’s SSH key, installed persistence, and sent host details to the attacker. Follow the remediation steps below regardless.” Microsoft makes the same point: the loader “may still write keys and persistence artifacts even when tunnel access fails.” If a machine already had Remote Login or an SSH server enabled, or someone turns it on later, the planted key becomes a working door. And because the persistence lives outside the extension folder, <strong>uninstalling the extension is not enough</strong>.</p><h3>How to check your own machines</h3><p>If anyone on your team installed or updated Anthropic.claude-code 2.1.312 or 2.1.313 from the Cursor marketplace on macOS or Windows during the window on October 29, start with <a href="https://cursor.com/blog/claude-code-extension-security-notice">Cursor&#39;s notice</a>. It publishes a one-line check for the svc-telemetry key on macOS and Windows, downloadable remediation scripts for each platform, and the full list of indicators of compromise, including file hashes, the attacker IP and the key fingerprint. Use the official check and scripts rather than copies circulating on social media, and, as Microsoft advises, review the scripts before running them.</p><p>Microsoft’s guidance adds two useful points. First, preserve evidence before cleanup if you have a security team that will investigate. Second, do not rely on the comment string alone: confirm the key against the published fingerprint, because a missing marker “should not replace review of the installation, process, network, and persistence evidence.”</p><p>Separately from this incident, these are generic hygiene checks worth doing on any developer machine from time to time:</p><ul><li>Read your ~/.ssh/authorized_keys and make sure you recognize every line. Many laptops should have no entries at all.</li><li>On macOS, look through ~/Library/LaunchAgents for anything you did not install. A name that looks like an Apple component is not proof it is one.</li><li>On Windows, review Task Scheduler and your Startup folder for entries you cannot explain.</li><li>Check whether Remote Login (macOS) or OpenSSH Server (Windows) is on, and turn it off if you do not need it.</li></ul><p>If you find the indicators, the published guidance from both Cursor and Microsoft is consistent: isolate the host, remove the key, persistence and tunnel processes, consider rebuilding the machine, and rotate credentials and sessions that were reachable from it, from a different, trusted machine. Cursor notes that the limited environment variable collection “does not rule out later credential access over the backdoor.”</p><h3>Team controls that would have reduced the damage</h3><p>An eleven-hour window is short. Most of the controls below work by making sure your team is not among the first people to install a brand new version of anything.</p><h3>1. Approved publishers and an install cooldown</h3><p>Microsoft’s top recommendation for organizations using Cursor is to apply Cursor’s security-hardening guidance, “including restricting extensions to approved publishers and configuring an extension installation cooldown to delay installation of newly published versions.” A cooldown of even a day or two would have kept most machines out of this window entirely, because the bad versions were pulled within hours.</p><h3>2. An extension allowlist</h3><p>Treat IDE extensions like dependencies. Keep a short, reviewed list of extensions your team actually needs, block the rest through managed settings where your editor supports it, and review additions the way you would review a new package in production code.</p><h3>3. Least privilege on developer machines</h3><p>This incident is a direct case study for not running daily work as an administrator. The SSH-enable step failed on most machines precisely because the extension did not have admin rights. Cursor notes that the exception was “highly unusual circumstances where Cursor was started with administrator privileges.” Keep it that way. Also keep SSH servers off on laptops unless there is a clear reason.</p><h3>4. Short-lived credentials, rotated from a trusted machine</h3><p>The beacon collected environment variable names because long-lived secrets sitting in a shell environment are valuable. Prefer short-lived tokens from an identity provider or secrets manager over static keys in .env files and shell profiles. When something like this happens, rotate from a machine you trust, not from the one you are worried about.</p><h3>5. Monitoring that covers workstations</h3><p>Microsoft and Phorion both describe detections built on endpoint telemetry: new entries in authorized_keys, new LaunchAgents and scheduled tasks, unexpected outbound SSH. If your monitoring stops at servers and cloud accounts, your developers&#39; laptops are a blind spot. At minimum, track which extensions and versions are installed across the team so you can answer &quot;were we exposed?&quot; in minutes, not days.</p><h3>6. A short incident playbook for supply-chain alerts</h3><p>Decide in advance who checks the inventory, who runs the official remediation, and who rotates which credentials. When the advisory lands, you want a checklist, not a meeting.</p><h3>A pattern, not a one-off</h3><p>Earlier this week we wrote about <a href="https://medium.com/@lycore/the-binance-hack-reportedly-started-with-one-laptop-here-is-what-engineering-teams-should-fix-now-e27a4a4f534f">the Binance hack, which reportedly started with one compromised laptop</a>. The two incidents are very different, and nothing published links them. But they point at the same place: the individual engineer’s machine, where source code, cloud credentials, SSH keys and AI coding agents all live together.</p><p>AI coding tools make this more pressing, not less. They are popular, they update often, and teams install them quickly because they are useful. That is exactly the profile an attacker wants to imitate. If you are weighing which assistant to standardize on, we compared a couple of the main options in <a href="https://www.lycore.com/blog/claude-code-vs-github-copilot/">Claude Code vs GitHub Copilot</a>. Whatever you choose, how you install and update it matters as much as which one you pick.</p><h3>Opinion: workstations are the new perimeter</h3><p><em>This section is our opinion.</em></p><p>For years, security budgets have focused on production: servers, clusters, cloud accounts. The developer laptop got antivirus and disk encryption and was otherwise trusted. We think that model is out of date. A modern developer workstation holds source code, deploy keys, cloud sessions, and now AI agents that can run commands on the developer’s behalf. Anything that runs inside the IDE inherits all of that. In practice, your IDE is production.</p><p>That does not mean locking developers down until they cannot work. It means applying the boring controls we already use for production, like allowlists, delayed rollouts, least privilege, short-lived credentials and monitoring, to the place where production access actually starts. The teams that do this will treat the next malicious extension as a fifteen-minute inventory check. The teams that do not will spend a week wondering what an attacker could have reached.</p><h3>Sources</h3><ul><li><a href="https://cursor.com/blog/claude-code-extension-security-notice">Cursor: Security notice on Anthropic.claude-code 2.1.312 and 2.1.313</a> (Oct 29, updated Oct 30, 2026)</li><li><a href="https://www.microsoft.com/en-us/security/blog/2026/10/30/inside-malicious-claude-code-branded-cursor-extension-persistent-ssh-backdoor/">Microsoft Security Blog: Inside a malicious Claude Code-branded extension and its persistent SSH backdoor</a> (Oct 30, 2026)</li><li><a href="https://phorion.io/blog/offical-claude-code-vsx-compromise/">Phorion: Official Claude Code Extension Compromise advisory</a> (Oct 30, updated Oct 31, 2026)</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=13382771dc7f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Soft Deletes in Django: The Hidden Costs and a Safer Way to Do Them]]></title>
            <link>https://medium.com/@lycore/soft-deletes-in-django-the-hidden-costs-and-a-safer-way-to-do-them-a4ef9acfae58?source=rss-4093c9a41614------2</link>
            <guid isPermaLink="false">https://medium.com/p/a4ef9acfae58</guid>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[django]]></category>
            <dc:creator><![CDATA[Lycore]]></dc:creator>
            <pubDate>Fri, 30 Oct 2026 13:01:02 GMT</pubDate>
            <atom:updated>2026-10-30T13:01:02.516Z</atom:updated>
            <content:encoded><![CDATA[<h4>Adding a deleted_at column takes five minutes, but keeping deleted rows out of exports, constraints and relations takes a deliberate design, and this is the one we use.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Qv9PwDknWvJIyqOeMNWu1g.jpeg" /></figure><p>It usually starts with one bad afternoon. Someone on the support team deletes the wrong customer, and the only way back is a restore from last night’s backup, followed by hours of picking that customer’s rows out of the restored copy and putting them back in production. Once it’s over, the team agrees it must never happen again, and someone adds a deleted_at column to every model.</p><p>For a while it works. Then, months later, the problems start. Deleted customers show up in a finance export, because the export query was written before the change and nobody updated it. A user who closed their account can’t sign up again: their old, deleted account still holds the email address, and the unique constraint doesn’t know the difference. Then the order history page crashes with “Customer matching query does not exist” for an order whose customer was soft-deleted.</p><p>Soft deletes are a reasonable tool. The trouble is that a deleted_at column quietly changes the meaning of every query, constraint and relation that touches the table, and most teams find that out one bug at a time. This post covers those costs, a pattern that keeps them in one place, and the parts that still need care.</p><h3>What soft deletes change</h3><p>When a row can be “deleted” but still exist, a few things stop being true:</p><ul><li><strong>Every query has to exclude deleted rows.</strong> Every list view, search, report, export and count now needs deleted_at IS NULL. Miss it once and deleted data reappears, and that kind of bug rarely fails a test because the test data has no deleted rows in it.</li><li><strong>Unique constraints break.</strong> A plain unique constraint on email counts deleted rows too, so a deleted account blocks a new sign-up with the same address.</li><li><strong>Related rows don’t cascade.</strong> With a real delete, Django’s on_delete rules decide what happens to orders, addresses and tokens. A soft delete is just an UPDATE, so nothing happens to related rows unless you write the code for it.</li><li><strong>Erasure obligations remain.</strong> If you are subject to GDPR or a similar law, a soft-deleted customer’s personal data is still personal data you hold. Hiding a row is not erasing it.</li><li><strong>Tables and indexes grow.</strong> Deleted rows stay in the table and in every index, and most queries filter them out anyway. On tables with a lot of churn, that dead weight adds up.</li></ul><h3>A pattern that contains the costs</h3><p>The aim is to make the safe behaviour the default, so a developer who writes Customer.objects.filter(...) gets active rows without having to remember anything, while deleted rows are still reachable when you ask for them explicitly.</p><pre>from django.db import models<br>from django.utils import timezone<br></pre><pre>class SoftDeleteQuerySet(models.QuerySet):<br>    def delete(self):<br>        # Bulk soft delete: one UPDATE, no per-object delete() calls.<br>        return self.update(deleted_at=timezone.now())</pre><pre>    def hard_delete(self):<br>        return super().delete()</pre><pre>    def alive(self):<br>        return self.filter(deleted_at__isnull=True)</pre><pre>    def dead(self):<br>        return self.filter(deleted_at__isnull=False)<br></pre><pre>class ActiveManager(models.Manager.from_queryset(SoftDeleteQuerySet)):<br>    def get_queryset(self):<br>        return super().get_queryset().filter(deleted_at__isnull=True)<br></pre><pre>class SoftDeleteModel(models.Model):<br>    deleted_at = models.DateTimeField(null=True, blank=True, editable=False)</pre><pre>    objects = ActiveManager()                       # first, so it is the default manager<br>    all_objects = SoftDeleteQuerySet.as_manager()   # everything, including deleted rows</pre><pre>    class Meta:<br>        abstract = True</pre><pre>    def delete(self, using=None, keep_parents=False):<br>        self.deleted_at = timezone.now()<br>        self.save(update_fields=[&quot;deleted_at&quot;])</pre><pre>    def hard_delete(self, using=None, keep_parents=False):<br>        return super().delete(using=using, keep_parents=keep_parents)</pre><pre>    def restore(self):<br>        self.deleted_at = None<br>        self.save(update_fields=[&quot;deleted_at&quot;])</pre><p>Order matters here. Django treats the first manager declared on a model as its <a href="https://docs.djangoproject.com/en/stable/topics/db/managers/#default-managers">default manager</a>, and several parts of the framework use the default manager, including the admin’s change list and dumpdata. Declaring objects = ActiveManager() first means those all see active rows only. all_objects is the explicit escape hatch for the places that really need everything: support tools, restore flows and purge jobs.</p><p>Two details are easy to miss:</p><ul><li>QuerySet.delete() is a bulk operation and never calls delete() on your model instances. That is why the queryset needs its own override. Without it, Customer.objects.filter(...).delete() would skip Customer.delete() and permanently remove the rows.</li><li>Our queryset delete() returns the number of rows updated, while Django&#39;s normally returns a tuple of the total count and a per-model breakdown. Code that unpacks the result of .delete() needs adjusting.</li></ul><p>Also bear in mind that update() does not call save() and does not send pre_save or post_save signals. If something in your project listens for those signals (search indexing, cache invalidation, audit logging), a bulk soft delete won&#39;t trigger it. Either loop over the objects and call delete() on each one, or have the queryset send whatever event your listeners need.</p><p>dumpdata deserves a note of its own. Because it uses the default manager, a fixture or backup made with plain dumpdata will leave out every soft-deleted row. If you use it for anything that needs to be complete, pass --all, which makes it use the base manager instead.</p><h3>Fix uniqueness with partial constraints</h3><p>The sign-up bug has a clean fix at the database level: enforce uniqueness only among active rows.</p><pre>from django.db import models<br>from django.db.models import Q<br></pre><pre>class Customer(SoftDeleteModel):<br>    email = models.EmailField()<br>    name = models.CharField(max_length=200)</pre><pre>    class Meta:<br>        constraints = [<br>            models.UniqueConstraint(<br>                fields=[&quot;email&quot;],<br>                condition=Q(deleted_at__isnull=True),<br>                name=&quot;uniq_active_customer_email&quot;,<br>            ),<br>        ]</pre><p>On PostgreSQL this becomes a <a href="https://www.postgresql.org/docs/current/indexes-partial.html">partial unique index</a>, CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL. Any number of deleted rows can share an email address, but only one active row can hold it. Not every backend supports <a href="https://docs.djangoproject.com/en/stable/ref/models/constraints/#condition">conditional constraints</a>, though. Django documents that MySQL and MariaDB ignore the condition, and Oracle doesn&#39;t support partial indexes, so check your database before you rely on this.</p><p>A few refinements are worth considering:</p><ul><li><strong>Case-insensitive emails.</strong> If Alice@Example.com and alice@example.com should count as the same person, use an expression instead of a field: models.UniqueConstraint(Lower(&quot;email&quot;), condition=Q(deleted_at__isnull=True), name=&quot;uniq_active_customer_email_ci&quot;), with Lower imported from django.db.models.functions.</li><li><strong>Constraints on the abstract base class.</strong> Constraint names must be unique, and anything in Meta on an abstract model is inherited unchanged by every subclass. If you put a shared constraint on SoftDeleteModel, include %(app_label)s_%(class)s in the name so each concrete model gets its own.</li><li><strong>Form validation.</strong> Django checks constraints during model validation, so a ModelForm reports the duplicate email as a normal form error, and the user doesn&#39;t get a 500 from the database.</li></ul><p>Restoring now has a sharp edge, and that’s a good thing. If a deleted customer’s email has since been taken by a new active account, restore() fails with an IntegrityError. The database is refusing to let two active customers share an email, which is exactly the rule you wanted. Turn that into a clear message rather than a crash:</p><pre>from django.db import IntegrityError, transaction<br></pre><pre>class RestoreConflict(Exception):<br>    pass<br></pre><pre>def restore_customer(customer):<br>    try:<br>        with transaction.atomic():<br>            customer.restore()<br>    except IntegrityError as exc:<br>        raise RestoreConflict(<br>            f&quot;{customer.email} now belongs to another active account.&quot;<br>        ) from exc</pre><p>The atomic() block matters on PostgreSQL. After an error, the current transaction can&#39;t be used until it&#39;s rolled back, and the savepoint keeps the failure contained.</p><h3>Know where the manager doesn’t apply</h3><p>The default manager covers queries that start from Customer.objects. Several common paths never touch it:</p><ul><li><strong>Forward relations.</strong> order.customer is loaded through the model&#39;s base manager, not the default manager. Django does this on purpose so that a related object can always be fetched. The order page in our story crashed because a view looked the customer up with Customer.objects.get(pk=order.customer_id), which goes through the filtered manager and raises DoesNotExist. Decide what a page should show for a deleted customer (often their name plus a &quot;deleted&quot; label), and don&#39;t try to &quot;fix&quot; this by pointing Meta.base_manager_name at a filtering manager. Django&#39;s documentation <a href="https://docs.djangoproject.com/en/stable/topics/db/managers/#base-managers">says explicitly</a> not to filter in a base manager.</li><li><strong>Filters across relations.</strong> Order.objects.filter(customer__country=&quot;PK&quot;) joins to the customer table directly, and the join does not apply Customer&#39;s default manager. Orders of deleted customers are included unless you add customer__deleted_at__isnull=True yourself.</li><li><strong>Aggregations through relations.</strong> Customer.objects.annotate(order_count=Count(&quot;orders&quot;)) counts soft-deleted orders too. If orders can be soft-deleted, filter inside the aggregate:</li></ul><pre>from django.db.models import Count, Q</pre><pre>Customer.objects.annotate(<br>    order_count=Count(&quot;orders&quot;, filter=Q(orders__deleted_at__isnull=True))<br>)</pre><ul><li><strong>Raw SQL, database views and BI tools.</strong> Anything that reads the table without going through the ORM sees every row. For reporting, a database view such as active_customers that applies WHERE deleted_at IS NULL gives analysts a safe default, and you only have to write the filter once.</li></ul><p>Write a test for each case. These are exactly where a refactor quietly reintroduces deleted data.</p><h3>Handle related rows explicitly</h3><p>Since a soft delete doesn’t cascade, decide per relation what should happen and write it down in code. A useful trick is to give the parent and its children the same timestamp, so a restore can bring back exactly the rows that went with it, and leave alone the ones that were deleted separately before.</p><pre>from django.db import transaction<br>from django.utils import timezone<br></pre><pre>class Customer(SoftDeleteModel):<br>    # fields and Meta as above</pre><pre>    def delete(self, using=None, keep_parents=False):<br>        now = timezone.now()<br>        with transaction.atomic():<br>            # Reverse relations use the default manager, so rows that<br>            # were already deleted keep their original timestamp.<br>            self.addresses.all().update(deleted_at=now)<br>            self.api_tokens.all().update(deleted_at=now)<br>            self.deleted_at = now<br>            self.save(update_fields=[&quot;deleted_at&quot;])</pre><pre>    def restore(self):<br>        with transaction.atomic():<br>            Address.all_objects.filter(<br>                customer=self, deleted_at=self.deleted_at<br>            ).update(deleted_at=None)<br>            ApiToken.all_objects.filter(<br>                customer=self, deleted_at=self.deleted_at<br>            ).update(deleted_at=None)<br>            self.deleted_at = None<br>            self.save(update_fields=[&quot;deleted_at&quot;])</pre><p>Not every relation should follow the parent. Orders and invoices are usually business records that need to outlive the customer, so they keep pointing at the soft-deleted row, and the pages that display them handle that state. Access tokens are the opposite: you may want to hard-delete or revoke them straight away, because a soft-deleted token that some code path still accepts is a security bug.</p><h3>Give staff a view of deleted rows in the admin</h3><p>The admin builds its change list from the default manager, so out of the box staff can’t see deleted rows at all. Support staff, who get asked to bring customers back, need to see them. A small ModelAdmin covers it:</p><pre>from django.contrib import admin, messages<br></pre><pre>class DeletedFilter(admin.SimpleListFilter):<br>    title = &quot;status&quot;<br>    parameter_name = &quot;deleted&quot;</pre><pre>    def lookups(self, request, model_admin):<br>        return [(&quot;no&quot;, &quot;Active&quot;), (&quot;yes&quot;, &quot;Deleted&quot;)]</pre><pre>    def queryset(self, request, queryset):<br>        if self.value() == &quot;yes&quot;:<br>            return queryset.dead()<br>        if self.value() == &quot;no&quot;:<br>            return queryset.alive()<br>        return queryset<br></pre><pre>@admin.register(Customer)<br>class CustomerAdmin(admin.ModelAdmin):<br>    list_display = [&quot;email&quot;, &quot;name&quot;, &quot;deleted_at&quot;]<br>    list_filter = [DeletedFilter]<br>    actions = [&quot;restore_selected&quot;]</pre><pre>    def get_queryset(self, request):<br>        qs = Customer.all_objects.all()<br>        ordering = self.get_ordering(request)<br>        return qs.order_by(*ordering) if ordering else qs</pre><pre>    @admin.action(description=&quot;Restore selected customers&quot;)<br>    def restore_selected(self, request, queryset):<br>        restored, conflicts = 0, []<br>        for customer in queryset.dead():<br>            try:<br>                restore_customer(customer)<br>                restored += 1<br>            except RestoreConflict as exc:<br>                conflicts.append(str(exc))<br>        self.message_user(request, f&quot;Restored {restored} customer(s).&quot;)<br>        for conflict in conflicts:<br>            self.message_user(request, conflict, level=messages.WARNING)</pre><p>Because the admin’s built-in “Delete selected” action calls queryset.delete(), it will soft-delete through our queryset. The confirmation page, though, is built by Django&#39;s deletion collector, so it may list related objects as about to be deleted when our soft delete won&#39;t touch them. If that would confuse your staff, override delete_queryset() and delete_model() to call your explicit soft-delete method, and consider removing the built-in action for this model.</p><h3>Test the boundaries</h3><p>The tests worth having are the ones that pin down each edge from the sections above. With pytest and pytest-django:</p><pre>import pytest<br>from django.db import IntegrityError<br>from django.db.models import Count, Q</pre><pre>pytestmark = pytest.mark.django_db<br></pre><pre>def test_default_manager_hides_deleted():<br>    customer = Customer.objects.create(email=&quot;a@example.com&quot;, name=&quot;A&quot;)<br>    customer.delete()<br>    assert not Customer.objects.filter(pk=customer.pk).exists()<br>    assert Customer.all_objects.filter(pk=customer.pk).exists()<br></pre><pre>def test_email_can_be_reused_after_delete():<br>    Customer.objects.create(email=&quot;a@example.com&quot;, name=&quot;Old&quot;).delete()<br>    Customer.objects.create(email=&quot;a@example.com&quot;, name=&quot;New&quot;)  # must not raise<br></pre><pre>def test_restore_conflict_is_rejected():<br>    old = Customer.objects.create(email=&quot;a@example.com&quot;, name=&quot;Old&quot;)<br>    old.delete()<br>    Customer.objects.create(email=&quot;a@example.com&quot;, name=&quot;New&quot;)<br>    with pytest.raises(RestoreConflict):<br>        restore_customer(Customer.all_objects.get(pk=old.pk))<br></pre><pre>def test_order_still_loads_its_deleted_customer():<br>    customer = Customer.objects.create(email=&quot;a@example.com&quot;, name=&quot;A&quot;)<br>    order = Order.objects.create(customer=customer)<br>    customer.delete()<br>    order = Order.objects.get(pk=order.pk)<br>    assert order.customer.deleted_at is not None<br></pre><pre>def test_order_count_ignores_deleted_orders():<br>    customer = Customer.objects.create(email=&quot;a@example.com&quot;, name=&quot;A&quot;)<br>    Order.objects.create(customer=customer)<br>    Order.objects.create(customer=customer).delete()<br>    row = Customer.objects.annotate(<br>        n=Count(&quot;orders&quot;, filter=Q(orders__deleted_at__isnull=True))<br>    ).get(pk=customer.pk)<br>    assert row.n == 1</pre><p>Run these against the same database engine you use in production. A conditional constraint that SQLite enforces tells you nothing about MySQL, and the reverse is also true.</p><h3>Adding soft deletes to an existing table</h3><p>Retrofitting a live table is mostly about ordering the steps so nothing breaks in between.</p><p><strong>Step 1: Add the column.</strong> In PostgreSQL, adding a nullable column with no default doesn’t rewrite the table, so this is fast even on large tables.</p><p><strong>Step 2: Add the partial unique index before dropping the old constraint.</strong> If the table is large and busy, remember that a normal index build blocks writes until it finishes. CREATE INDEX CONCURRENTLY avoids that but can&#39;t run inside a transaction, so the migration has to be non-atomic. Django&#39;s SeparateDatabaseAndState lets you run the concurrent SQL while still recording the constraint in Django&#39;s migration state:</p><pre>from django.db import migrations, models<br></pre><pre>class Migration(migrations.Migration):<br>    atomic = False<br>    dependencies = [(&quot;customers&quot;, &quot;0012_customer_deleted_at&quot;)]</pre><pre>    operations = [<br>        migrations.SeparateDatabaseAndState(<br>            database_operations=[<br>                migrations.RunSQL(<br>                    sql=(<br>                        &quot;CREATE UNIQUE INDEX CONCURRENTLY uniq_active_customer_email &quot;<br>                        &quot;ON customers_customer (email) WHERE deleted_at IS NULL;&quot;<br>                    ),<br>                    reverse_sql=(<br>                        &quot;DROP INDEX CONCURRENTLY IF EXISTS uniq_active_customer_email;&quot;<br>                    ),<br>                ),<br>            ],<br>            state_operations=[<br>                migrations.AddConstraint(<br>                    model_name=&quot;customer&quot;,<br>                    constraint=models.UniqueConstraint(<br>                        fields=[&quot;email&quot;],<br>                        condition=models.Q(deleted_at__isnull=True),<br>                        name=&quot;uniq_active_customer_email&quot;,<br>                    ),<br>                ),<br>            ],<br>        ),<br>    ]</pre><p><strong>Step 3: Drop the old </strong><strong>unique=True</strong> in a following migration, once the partial index exists. Until then, the old constraint still blocks reused emails, which is safe, just strict.</p><p><strong>Step 4: Audit the callers before switching the managers on.</strong> Search for .delete(, raw SQL, annotate( with counts across relations, exports, and anything that unpacks the return value of delete(). Each one is a decision: active rows, all rows, or a real delete.</p><p><strong>Step 5: Only then deploy the model change</strong> that makes objects the filtered manager.</p><h3>Consider the alternatives first</h3><p>Soft deletes are often used where a different tool would fit better:</p><ul><li><strong>A status field</strong> for business states. A cancelled subscription or a suspended account isn’t deleted. status = &quot;suspended&quot; says what actually happened, and queries can be explicit about which states they want.</li><li><strong>Audit history</strong> for the “undo” requirement. A package like <a href="https://django-simple-history.readthedocs.io/en/latest/">django-simple-history</a> records every version of a row, including the last one before a delete, so you can do a real delete and still reconstruct the record.</li><li><strong>An archive table.</strong> Move deleted rows into a separate table with the same shape. The main table stays clean, constraints keep working unchanged, and restoring is an explicit copy back.</li></ul><p>Whichever you choose, <strong>keep a real deletion path.</strong> hard_delete() must exist, and something must call it. A scheduled purge that permanently removes rows once their retention period has passed is what turns a soft delete into a recycle bin rather than a slow leak:</p><pre># customers/management/commands/purge_deleted_customers.py<br>from datetime import timedelta</pre><pre>from django.core.management.base import BaseCommand<br>from django.db import transaction<br>from django.utils import timezone</pre><pre>from customers.models import Customer<br></pre><pre>class Command(BaseCommand):<br>    help = &quot;Permanently delete customers soft-deleted before the retention cutoff.&quot;</pre><pre>    def add_arguments(self, parser):<br>        parser.add_argument(&quot;--days&quot;, type=int, default=30)<br>        parser.add_argument(&quot;--batch-size&quot;, type=int, default=500)<br>        parser.add_argument(&quot;--dry-run&quot;, action=&quot;store_true&quot;)</pre><pre>    def handle(self, *args, days, batch_size, dry_run, **options):<br>        cutoff = timezone.now() - timedelta(days=days)<br>        expired = Customer.all_objects.filter(deleted_at__lt=cutoff).order_by(&quot;pk&quot;)</pre><pre>        if dry_run:<br>            self.stdout.write(f&quot;{expired.count()} customer(s) would be purged.&quot;)<br>            return</pre><pre>        total = 0<br>        while True:<br>            pks = list(expired.values_list(&quot;pk&quot;, flat=True)[:batch_size])<br>            if not pks:<br>                break<br>            with transaction.atomic():<br>                Customer.all_objects.filter(pk__in=pks).hard_delete()<br>            total += len(pks)</pre><pre>        self.stdout.write(self.style.SUCCESS(f&quot;Purged {total} customer(s).&quot;))</pre><p>The hard delete goes through Django’s normal deletion collector, which finds related rows through the base manager, so soft-deleted children are removed along with the parent according to their on_delete rules. If orders point at customers with on_delete=PROTECT, the purge will fail with ProtectedError for customers who have orders. That is a decision to make on purpose: for those customers, the usual answer is to anonymise the personal fields and keep the row, so the financial records stay intact and the personal data goes. Batches keep each transaction short, and --dry-run gives you a number to check before the first real run.</p><h3>A short checklist</h3><ul><li>The filtered manager is declared first, and all_objects exists for explicit use.</li><li>Bulk QuerySet.delete() is overridden, and signal listeners are accounted for.</li><li>Unique fields use conditional constraints on a backend that supports them.</li><li>Restore handles IntegrityError with a clear message.</li><li>Pages that follow order.customer handle a deleted customer on purpose.</li><li>Cross-relation filters and aggregates exclude deleted rows explicitly.</li><li>Reports and BI tools read from a view that hides deleted rows.</li><li>Each related model has a decided behaviour: cascade, keep, or hard-delete.</li><li>dumpdata backups use --all.</li><li>A scheduled purge permanently deletes rows past retention, with a plan for protected relations.</li><li>Each of the above has a test running on your production database engine.</li></ul><h3>Conclusion</h3><p>Soft deletes look like a one-column change, but they really change what “exists” means across your whole data model. The pattern above doesn’t make that cost disappear. It gathers it into a small number of places you can see and test: a default manager that hides deleted rows, an explicit manager for when you need everything, constraints that understand deletion, deliberate decisions about related rows, and a purge job that actually deletes. If you can’t name who needs deleted rows back and for how long, a status field, audit history or archive table is likely the simpler answer. If you can, build it this way, and you’ll get the safety net without deleted customers turning up where they don’t belong.</p><p>Lycore builds and maintains production systems on Django, React, Flutter and .NET. Get in touch at lycore.com if deleted data keeps reappearing where it shouldn’t.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a4ef9acfae58" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>