<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 137Foundry</title>
    <description>The latest articles on DEV Community by 137Foundry (@137foundry).</description>
    <link>https://dev.to/137foundry</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3856342%2F39ac4be7-399f-4f6e-9a32-60abf8a8a324.png</url>
      <title>DEV Community: 137Foundry</title>
      <link>https://dev.to/137foundry</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/137foundry"/>
    <language>en</language>
    <item>
      <title>How to Run Old and New Systems in Parallel Without Doubling Your Workload</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Mon, 13 Jul 2026 11:34:32 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-run-old-and-new-systems-in-parallel-without-doubling-your-workload-1f5b</link>
      <guid>https://dev.to/137foundry/how-to-run-old-and-new-systems-in-parallel-without-doubling-your-workload-1f5b</guid>
      <description>&lt;p&gt;Running a legacy system and its replacement side by side, comparing outputs, is the single best way to catch gaps a migration audit missed. It's also expensive if done naively, because someone has to actually watch both systems and reconcile differences. Here's how to structure a parallel run that catches real problems without burning out whoever's assigned to monitor it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Feed both systems the same inputs automatically
&lt;/h2&gt;

&lt;p&gt;Manually re-entering the same data into two systems is slow and introduces its own errors. Set up a fork at the input layer, whether that's a message queue consumer, an API gateway, or a batch job, that sends every real input to both the legacy system and the replacement simultaneously. The legacy system's output remains authoritative during this phase; the replacement's output is being validated, not yet trusted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;legacy_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;legacy_system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;new_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;legacy_result&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;new_result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;log_discrepancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;legacy_result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;legacy_result&lt;/span&gt;  &lt;span class="c1"&gt;# legacy stays authoritative during validation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9syn4r7f8y9xi89efns3.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9syn4r7f8y9xi89efns3.jpeg" alt="a network operations center with rows of monitors" width="799" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by ThisIsEngineering on &lt;a href="https://www.pexels.com" rel="noopener noreferrer"&gt;Pexels&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Automate the comparison, don't do it by eye
&lt;/h2&gt;

&lt;p&gt;Diffing two outputs manually works for the first ten transactions and becomes untenable at any real volume. Write an automated comparison that flags discrepancies and logs them with enough context (the input, both outputs, a timestamp) to investigate later without needing to reproduce the exact conditions from scratch. This is the step that turns a parallel run from "hopefully someone notices something's wrong" into an actual verification process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Triage discrepancies by pattern, not by individual occurrence
&lt;/h2&gt;

&lt;p&gt;Once discrepancies start accumulating, group them by root cause rather than investigating each one individually. A single missed edge case (a specific date range, a specific customer type) might produce hundreds of individual discrepancy log entries that are all actually the same underlying bug. Fixing the pattern once, rather than triaging each occurrence separately, is how a parallel run scales past the first few days without becoming a full-time job for whoever's assigned to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Set an explicit exit criteria before you start
&lt;/h2&gt;

&lt;p&gt;Decide in advance what "the parallel run is done" looks like: a specific number of consecutive days with zero unexplained discrepancies, or a specific percentage of transaction volume validated without issue. Without an explicit target, parallel runs tend to continue indefinitely out of caution, which delays the actual benefit of retiring the legacy system and keeps two systems in production maintenance simultaneously longer than necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Cover a full business cycle if the system has one
&lt;/h2&gt;

&lt;p&gt;If the legacy system handles anything with a monthly, quarterly, or annual pattern (billing cycles, tax calculations, seasonal logic), make sure the parallel run's duration actually covers that cycle at least once. A parallel run that only spans two weeks will miss a bug that only manifests during month-end processing, and that gap won't surface until the legacy system is already gone and there's nothing left to compare against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling discrepancies that turn out to be the legacy system's bug
&lt;/h2&gt;

&lt;p&gt;Not every discrepancy means the new system is wrong. Occasionally the parallel run surfaces a case where the legacy system was actually producing an incorrect result all along, one nobody had noticed because nothing downstream ever checked it carefully. This is a genuinely useful finding, but it needs a deliberate decision, not a default assumption either way. Confirm which system is actually correct against the real business rule (not just which one is older) before deciding whether the "discrepancy" is really the new system introducing a regression or the audit accidentally surfacing a bug in a system everyone had assumed was reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this catches that a code review can't
&lt;/h2&gt;

&lt;p&gt;The value of an actual parallel run, as opposed to a thorough code review of the replacement, is that it exercises real production data with all its genuine messiness: malformed inputs nobody anticipated, edge cases that occur rarely enough that no test suite happened to cover them, timing-dependent behavior that only shows up under real load. Code review catches what reviewers think to look for. A parallel run catches what actually happens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://137foundry.com/services/data-integration" rel="noopener noreferrer"&gt;137Foundry's engineering team&lt;/a&gt; builds this kind of parallel-run infrastructure as a standard phase of legacy migration work, treating it as a verification gate rather than an optional nice-to-have. The &lt;a href="https://137foundry.com/articles/sunset-legacy-system-without-losing-institutional-knowledge" rel="noopener noreferrer"&gt;complete legacy retirement framework&lt;/a&gt; covers how this fits alongside the audit and knowledge-recovery work that should happen before the parallel run even starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://en.wikipedia.org/wiki/Legacy_system" rel="noopener noreferrer"&gt;Wikipedia's entry on legacy systems&lt;/a&gt; for background on why these systems accumulate the kind of edge-case behavior that a parallel run is designed to catch.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://martinfowler.com/" rel="noopener noreferrer"&gt;Martin Fowler's website&lt;/a&gt; covers software architecture patterns including strangler fig migrations, a closely related approach to gradually replacing a legacy system rather than a single cutover.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/" rel="noopener noreferrer"&gt;MDN's web docs&lt;/a&gt; is a useful general reference if the comparison layer you're building involves web APIs or HTTP-level request forking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A parallel run is real infrastructure work, not a formality. Budgeted properly, it's also the cheapest insurance available against shipping a replacement system that's subtly, silently wrong in ways nobody catches until months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding when a parallel run isn't worth building
&lt;/h2&gt;

&lt;p&gt;Not every migration justifies this level of infrastructure. For a low-volume, low-stakes internal system with a thorough audit behind it, the engineering cost of building input forking and automated comparison can exceed the actual risk being mitigated. Reserve the full parallel-run treatment for systems where a wrong answer has real business consequences, financial, legal, or customer-facing, and lean on a simpler validation approach, like spot-checking a sample of transactions manually, for lower-stakes systems where the infrastructure investment wouldn't pay for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on team morale during the validation period
&lt;/h2&gt;

&lt;p&gt;Running two systems in parallel for weeks or months is genuinely tedious for whoever's assigned to monitor it, especially once the interesting discrepancies have mostly been found and fixed and what remains is mostly confirming that things continue to work correctly. Rotating this responsibility across the team, rather than assigning it permanently to one person, keeps the monitoring rigorous instead of becoming an afterthought that one burned-out engineer checks less and less carefully as the weeks go on. It's a small operational detail, but it's one of the more common reasons parallel runs quietly stop catching real problems well before their planned end date.&lt;/p&gt;

&lt;p&gt;Pair the rotation with a lightweight weekly summary, even a few sentences, of what the past week's discrepancies looked like and whether the rate is trending down. This keeps the whole team oriented on whether the validation period is actually converging toward "safe to cut over" or stalling on a recurring issue that needs deeper investigation, rather than everyone individually assuming someone else is tracking the trend.&lt;/p&gt;

&lt;p&gt;A short summary is enough. The point isn't a formal report, it's making sure the trend is visible to the whole team rather than living only in whoever happens to be on monitoring duty that particular week, which is what actually lets the team collectively decide when the exit criteria has genuinely been met, rather than one person's gut feeling carrying that decision alone.&lt;/p&gt;

</description>
      <category>migration</category>
      <category>legacy</category>
      <category>programming</category>
      <category>api</category>
    </item>
    <item>
      <title>7 Free Tools for Auditing an Old Codebase Before You Migrate It</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Mon, 13 Jul 2026 11:34:30 +0000</pubDate>
      <link>https://dev.to/137foundry/7-free-tools-for-auditing-an-old-codebase-before-you-migrate-it-19hd</link>
      <guid>https://dev.to/137foundry/7-free-tools-for-auditing-an-old-codebase-before-you-migrate-it-19hd</guid>
      <description>&lt;p&gt;Before replacing a legacy system, you need an honest picture of what it actually does, not what the original documentation says it does or what everyone assumes. These free tools cover the inspection work that should happen before a single line of replacement code gets written.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv4mcxqxziibbo3mtxnad.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv4mcxqxziibbo3mtxnad.jpeg" alt="server racks with organized network cables" width="800" height="1202"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by Brett Sayles on &lt;a href="https://www.pexels.com" rel="noopener noreferrer"&gt;Pexels&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your version control's blame and log commands
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git log --follow&lt;/code&gt; and &lt;code&gt;git blame&lt;/code&gt; on the files that make up the core business logic tell you when a piece of code was introduced and, often, why, if the original commit message was written with any care. This is free, it's already in your repository, and it's the fastest way to distinguish a rule that's been stable for a decade from one that was a quick patch six weeks ago. &lt;a href="https://git-scm.com/" rel="noopener noreferrer"&gt;Git's own documentation&lt;/a&gt; covers both commands if your team isn't already using them for this kind of archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A static analysis tool for dead code detection
&lt;/h2&gt;

&lt;p&gt;Legacy systems accumulate code paths that no longer execute, conditionals guarding for a state that can't happen anymore, whole functions nothing calls. Tools like &lt;a href="https://github.com/jendrikseipp/vulture" rel="noopener noreferrer"&gt;Vulture&lt;/a&gt; for Python or built-in dead code warnings in most modern compilers help separate logic that's still load-bearing from logic that's safe to simply not carry forward into a replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A dependency graph visualizer
&lt;/h2&gt;

&lt;p&gt;Understanding what actually calls what, especially in a codebase that's grown organically over many years, is much easier with a visual dependency graph than by reading files in whatever order they happen to be organized. Tools like &lt;a href="https://graphviz.org/" rel="noopener noreferrer"&gt;Graphviz&lt;/a&gt; can render a call graph or module dependency graph from most languages' static analysis output, making it far easier to spot which parts of the system are actually central versus which are isolated, rarely touched, and lower-risk to leave for last.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A test coverage report, even a rough one
&lt;/h2&gt;

&lt;p&gt;Legacy systems are notorious for having little or no automated test coverage, but generating even a rough coverage report (which lines execute during a typical run, which never do) tells you where the system's actual behavior is exercised regularly versus where it's dormant. Dormant code paths deserve extra scrutiny during a migration precisely because nobody's recently confirmed they still work correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A database schema visualizer
&lt;/h2&gt;

&lt;p&gt;If the legacy system's business logic lives partly in stored procedures, triggers, or an unusually complex schema (common in systems old enough to predate modern ORMs), a schema visualization tool like &lt;a href="http://schemaspy.org/" rel="noopener noreferrer"&gt;SchemaSpy&lt;/a&gt; surfaces relationships and constraints that aren't obvious from reading application code alone. This matters especially for systems where business rules were implemented at the database layer rather than the application layer, which is a common pattern in systems built before that separation became standard practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A log aggregator, even a temporary one
&lt;/h2&gt;

&lt;p&gt;If the legacy system produces logs at all, pulling them into a searchable aggregator, even a temporary open-source one like the ELK stack (&lt;a href="https://www.elastic.co/" rel="noopener noreferrer"&gt;Elastic's website&lt;/a&gt;) or a simpler alternative, lets you search for patterns across months of production history instead of reading log files one at a time. This is especially useful for confirming how often a specific rare code path actually fires, information that directly feeds into the frequency-times-risk prioritization that should guide how deep an audit goes on any individual finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A documentation generator for whatever comments do exist
&lt;/h2&gt;

&lt;p&gt;Even sparse or outdated inline comments are worth extracting into a single searchable document rather than left scattered across hundreds of files. Tools like &lt;a href="https://www.doxygen.nl/" rel="noopener noreferrer"&gt;Doxygen&lt;/a&gt; or language-specific documentation generators pull every comment into one browsable reference, which makes it much faster to spot patterns (a comment style that shows up around every workaround, for instance) than reading file by file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting these together with the human side of the audit
&lt;/h2&gt;

&lt;p&gt;None of these tools replace talking to the people who currently use or maintain the system. What they do is make those conversations more productive, because you can ask specific, informed questions ("I see this function hasn't been touched in eight years and nothing in our test suite exercises it, what does it actually do") instead of vague ones. The tooling surfaces where to look. The interviews explain why what you're looking at exists in the first place.&lt;/p&gt;

&lt;p&gt;For a fuller framework on how this technical audit fits into a complete legacy retirement process, including how to prioritize what to preserve and how to structure a parallel-run verification period before fully decommissioning the old system, see &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;https://137foundry.com&lt;/a&gt;'s guide on &lt;a href="https://137foundry.com/articles/sunset-legacy-system-without-losing-institutional-knowledge" rel="noopener noreferrer"&gt;sunsetting a legacy system without losing institutional knowledge&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on scope
&lt;/h2&gt;

&lt;p&gt;None of these five tools are expensive or complicated to set up, which is part of the point. The barrier to a proper legacy audit is rarely tooling. It's making the time for it in a project plan that's usually under pressure to get to the "real" migration work as quickly as possible. Running these tools takes an afternoon. Skipping them and discovering the gaps in production takes considerably longer, spread out over months of confusing, hard-to-diagnose incidents.&lt;/p&gt;

&lt;p&gt;Budget the audit phase as its own line item, with its own estimated hours, rather than folding it silently into "planning," where it tends to get compressed first whenever a deadline gets tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combining tool output with human context
&lt;/h2&gt;

&lt;p&gt;None of these seven tools tell you why a piece of logic exists, only that it exists and roughly how often it runs. The tooling narrows down where the interesting questions are; answering them still requires talking to whoever's closest to the system. A useful workflow is running the technical audit first, compiling a short list of the specific business-logic questions it raised, and then bringing that concrete list into interviews rather than starting the interviews cold. People give much more specific, useful answers when asked "why does this specific condition on line 340 exist" than when asked a general "tell me everything you know about this system."&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the audit from becoming its own multi-month project
&lt;/h2&gt;

&lt;p&gt;There's a real risk of over-investing in tooling and analysis to the point where the audit itself becomes the bottleneck. Time-box each tool's pass: a day or two for the dependency graph, a day for the dead code scan, rather than letting any single step expand indefinitely. The goal is a good-enough map of the system's actual behavior to inform the interviews and the migration plan, not an exhaustive, perfectly complete catalog of every line of code.&lt;/p&gt;

&lt;p&gt;A reasonable target for a mid-sized legacy system is a week of tooling-based audit work before moving into the interview phase. If a specific tool's pass is taking noticeably longer than that without converging on useful findings, that's usually a signal to move on with what you have rather than keep digging for diminishing returns.&lt;/p&gt;

&lt;p&gt;Keep a running list of open questions the tooling couldn't answer on its own. That list becomes the actual agenda for the interview phase, which keeps those conversations focused and efficient instead of open-ended and hard to schedule around, and it respects the interviewee's time by showing up with specific questions rather than an open-ended request to "tell us everything."&lt;/p&gt;

</description>
      <category>legacy</category>
      <category>migration</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Write a CLAUDE.md or .cursorrules File That Actually Gets Followed</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sun, 12 Jul 2026 11:30:48 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-write-a-claudemd-or-cursorrules-file-that-actually-gets-followed-17om</link>
      <guid>https://dev.to/137foundry/how-to-write-a-claudemd-or-cursorrules-file-that-actually-gets-followed-17om</guid>
      <description>&lt;p&gt;Plenty of teams have a &lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;.cursorrules&lt;/code&gt; file sitting in their repo root that nobody has touched since the day they created it, and it shows in the output. Writing the file isn't the hard part. Writing one specific enough to change behavior, without becoming so long it gets skimmed, is the actual skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Audit What You're Actually Repeating in Chat
&lt;/h2&gt;

&lt;p&gt;Before writing anything, spend a week paying attention to what you type into the assistant chat over and over. "Use our &lt;code&gt;ApiError&lt;/code&gt; class, not bare exceptions." "We're on Vitest, not Jest." "Don't touch the billing module without asking." Whatever you find yourself typing more than twice belongs in the file. Everything else is probably noise.&lt;/p&gt;

&lt;p&gt;This audit step matters because it grounds the file in real friction instead of a generic checklist copied from someone else's repo. A file built from your team's actual repeated corrections is shorter and more effective than one built from guessing what might be useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Separate Durable Rules From Temporary Ones
&lt;/h2&gt;

&lt;p&gt;"We use PostgreSQL" is a durable rule. "We're mid-migration off the old billing service, don't extend it" is temporary and needs a review date. Mixing the two without marking which is which means the temporary rules quietly outlive their relevance and start confusing new team members and assistant sessions alike, sometimes for years after the migration finished.&lt;/p&gt;

&lt;p&gt;A simple convention works well here: put temporary rules in their own section with a target removal date in a comment. Review that section on a schedule, even a rough quarterly one, and delete what's no longer true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Point at Examples Instead of Describing Patterns Abstractly
&lt;/h2&gt;

&lt;p&gt;"Follow the pattern in &lt;code&gt;handlers/users.py&lt;/code&gt; for new API handlers" is more effective than three paragraphs describing what a good handler looks like. Assistants read code better than they read abstract prose about code. If your codebase already has a clean example of the pattern you want repeated, cite the file path directly.&lt;/p&gt;

&lt;p&gt;This only works if the example file is actually current. An outdated example is worse than no example, since it actively points the assistant toward a pattern you've since moved away from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Add an Explicit "Ask First" List
&lt;/h2&gt;

&lt;p&gt;Name the modules, files, or operations that need a human in the loop before the assistant touches them: schema migrations, auth logic, anything touching payment processing. This is the single highest-value line item for teams worried about AI-generated changes causing real damage, and it's often missing entirely from instructions files that otherwise look thorough.&lt;/p&gt;

&lt;p&gt;This list matters more than almost anything else in the file precisely because the failure mode it prevents is asymmetric. A wrong guess about which HTTP client to use costs a few minutes in review. A wrong guess about a database migration or an authorization check can cost considerably more, and an assistant with no signal that a file is dangerous treats it with exactly the same confidence it applies to a throwaway script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4b: Test the Rules Against a Real Task
&lt;/h2&gt;

&lt;p&gt;Before considering the file finished, run one real task through it and check whether the assistant actually followed the rules you wrote. This sounds obvious, but it's the step most teams skip, and it's the fastest way to find out that a rule you thought was clear was actually ambiguous. If the assistant still reaches for the wrong library after you've told it not to, the instruction probably needs a concrete file reference rather than a general statement, since assistants tend to follow specific pointers to real code more reliably than abstract descriptions of intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Keep It Under a Page and Revisit It in Pull Requests
&lt;/h2&gt;

&lt;p&gt;A 2,000-word instructions file gets skimmed by humans and effectively deprioritized by the assistant's limited attention to any single piece of context. Aim for something a new hire could read in two minutes. When a convention changes, update the file in the same pull request that changes the code, not as a separate cleanup task that never happens.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The instructions files that actually work are the boring ones, five or six specific rules instead of an essay about engineering philosophy." - Dennis Traina, &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;founder of 137Foundry&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 6: Treat It Like Any Other Documentation Debt
&lt;/h2&gt;

&lt;p&gt;Instructions files rot the same way READMEs rot: quietly, until someone notices the assistant is confidently doing something the team stopped doing months ago. Assign it an owner, even an informal one, and check it during any sprint where the team makes a significant architectural decision.&lt;/p&gt;

&lt;p&gt;Version control history is your friend here. If you're not sure whether a rule is still accurate, &lt;code&gt;git log&lt;/code&gt; on the file itself usually tells you when it was last touched and by whom, which is a fast way to spot the stale sections before they cause a confusing pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Fits With the Rest of Your Tooling
&lt;/h2&gt;

&lt;p&gt;An instructions file isn't a replacement for the mechanical tooling your team should already have in place. &lt;a href="https://eslint.org" rel="noopener noreferrer"&gt;ESLint&lt;/a&gt; and &lt;a href="https://prettier.io" rel="noopener noreferrer"&gt;Prettier&lt;/a&gt; handle formatting and a class of common mistakes automatically, which frees the instructions file to focus on the judgment calls a linter can't make: which of two valid architectural patterns your team has actually settled on, and which parts of the codebase need extra caution.&lt;/p&gt;

&lt;p&gt;Whichever tool your team standardizes on, whether that's &lt;a href="https://cursor.com" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt; or something else entirely, the instructions file format has converged enough across the ecosystem that the same document usually works with minor adjustments regardless of which assistant reads it. That portability is one more reason the file is worth getting right the first time instead of treating it as tool-specific throwaway configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Realistic Timeline
&lt;/h2&gt;

&lt;p&gt;None of this needs to happen in one sitting. A reasonable rollout looks like a rough first draft in an afternoon, based on whatever repeated corrections you can recall off the top of your head, followed by two or three weeks of quietly noting new corrections as they come up in real sessions. At that point, a short revision pass usually captures the rules that actually matter, and the file is in good enough shape to stop actively iterating on and start just maintaining.&lt;/p&gt;

&lt;p&gt;Trying to get it perfect on the first attempt tends to backfire, producing either an overlong document nobody reads or a set of rules based on guesses about what might matter rather than what actually does. The iterative version, grounded in real repeated friction, consistently ends up shorter, more specific, and more likely to actually change the assistant's output.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Consider It Done
&lt;/h2&gt;

&lt;p&gt;There's no permanent finished state for a file like this, but there is a reasonable point to stop actively iterating and shift to maintenance mode: when new corrections in chat sessions become rare rather than routine. At that point the file has captured most of what the team actually needed documented, and further additions are more likely to be edge cases that don't generalize well than genuinely useful rules. Keep the maintenance habit, the update-it-in-the-same-pull-request discipline, but stop treating the file as a work in progress once it's earned that stability.&lt;/p&gt;

&lt;p&gt;We cover the broader codebase structure that makes an instructions file effective, not just present, in &lt;a href="https://137foundry.com/articles/how-to-structure-codebase-ai-coding-assistants-stop-guessing" rel="noopener noreferrer"&gt;our guide to structuring a codebase so AI coding assistants stop guessing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The development team at &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt; has written and maintained these files across a range of client codebases, and the pattern that works is consistently the same: short, specific, and updated in the same breath as the code it describes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>7 Free Tools for Auditing What Your AI Coding Assistant Actually Changed</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sun, 12 Jul 2026 11:30:44 +0000</pubDate>
      <link>https://dev.to/137foundry/7-free-tools-for-auditing-what-your-ai-coding-assistant-actually-changed-7g2</link>
      <guid>https://dev.to/137foundry/7-free-tools-for-auditing-what-your-ai-coding-assistant-actually-changed-7g2</guid>
      <description>&lt;p&gt;Trusting an AI coding assistant's summary of its own changes is a mistake most teams only make once. The summary says "updated the validation logic," the diff says it also touched three unrelated files and quietly changed a default timeout. These tools make the actual diff impossible to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Git Itself
&lt;/h2&gt;

&lt;p&gt;The most underused audit tool is the one already installed. &lt;code&gt;git diff --stat&lt;/code&gt; before you even open a pull request tells you the blast radius of a change in one line. If an assistant claims a "small fix" and the stat shows twelve files touched, that's worth a second look before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;a href="https://github.com" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; Pull Request Review Tools
&lt;/h2&gt;

&lt;p&gt;GitHub's native diff view, combined with required reviewers on any AI-assisted branch, is still the backbone of most teams' audit process. Pair it with branch protection rules so nothing merges without at least one human set of eyes, regardless of how confident the assistant's own commit message sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;a href="https://eslint.org" rel="noopener noreferrer"&gt;ESLint&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Configured with rules against unused variables, implicit any types, and suspicious patterns, ESLint catches the class of mistake assistants make when they're pattern-matching quickly: leftover debug code, unused imports from an abandoned approach, or subtly wrong comparison operators.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;a href="https://prettier.io" rel="noopener noreferrer"&gt;Prettier&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Not a bug catcher on its own, but a fast way to normalize formatting differences so a human reviewer's diff isn't cluttered with whitespace noise next to the actual logic change. Run it as a pre-commit step so every diff, human or AI-assisted, arrives in the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Static Analysis via &lt;a href="https://www.sonarsource.com/products/sonarqube/" rel="noopener noreferrer"&gt;SonarQube&lt;/a&gt; or Similar
&lt;/h2&gt;

&lt;p&gt;A static analysis pass flags the categories of mistakes both a rushed human reviewer and the assistant itself are prone to miss: security-sensitive patterns, unreachable code, and complexity spikes in functions that used to be simple before a "quick fix" ballooned them.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;a href="https://vitest.dev" rel="noopener noreferrer"&gt;Vitest&lt;/a&gt; or Your Existing Test Runner, Run on Every AI-Assisted Commit
&lt;/h2&gt;

&lt;p&gt;Tests are the fastest audit tool you already have if your suite has real coverage. Running the full suite, not just the tests touching the changed files, catches the cases where an assistant's change had a side effect nobody anticipated in a file three layers removed from the one it edited.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A Simple Changelog Convention
&lt;/h2&gt;

&lt;p&gt;Not a tool exactly, but worth including here because it changes how every other tool on this list gets used: require a one-line note in the pull request description explaining why the change was made, written by the human who reviewed it, not copied from the assistant's own summary. Six months later, this is the difference between understanding a change at a glance and reverse-engineering the reasoning from the diff alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting These Together Instead of Picking One
&lt;/h2&gt;

&lt;p&gt;None of these tools replace each other, and none of them are sufficient on their own. Git's diff stat tells you the blast radius but not the quality. ESLint catches mechanical mistakes but has no idea whether the logic is right for your specific business rules. Tests catch behavioral regressions but only for the paths someone thought to cover. The real audit process is all seven working together, each catching a different category of problem the others were never designed to catch.&lt;/p&gt;

&lt;p&gt;A useful way to think about ordering: run the fast, cheap checks first. Prettier and ESLint take seconds and catch a meaningful share of issues before a human reviewer even opens the diff. Save the expensive step, a careful human read of the full changeset, for last, after the mechanical checks have already cleared out the noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Often Teams Skip This and Regret It
&lt;/h2&gt;

&lt;p&gt;The most common failure pattern isn't skipping all seven tools. It's skipping the review step specifically because the assistant's own summary sounded confident and complete. A summary that says "small refactor, no behavior change" is exactly the kind of claim that deserves the full diff read, not less scrutiny, because a confidently wrong summary is more dangerous than an honest "not sure, please check this carefully."&lt;/p&gt;

&lt;p&gt;Teams that build the habit of reading the actual diff before trusting any summary, human-written or assistant-written, catch a disproportionate share of the subtle bugs that would otherwise ship. It costs a few extra minutes per pull request and saves considerably more than that the first time it catches something real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the Habit Stick Past the First Month
&lt;/h2&gt;

&lt;p&gt;Most teams adopt some version of this checklist enthusiastically for a few weeks after a close call, then quietly let it slide once the memory of the incident fades. The tools that survive long-term are usually the ones wired into CI so they run automatically rather than the ones that depend on an individual remembering to run them manually before every merge. If ESLint, Prettier, and the test suite all run on every pull request without anyone having to trigger them by hand, the habit persists long after the original motivating incident is forgotten.&lt;/p&gt;

&lt;p&gt;The human steps, reading the full diff and writing an honest changelog line, are harder to automate and easier to let slip. Making them a required part of the pull request template, rather than a suggested best practice, is a small structural change that keeps them from quietly disappearing the way purely voluntary habits tend to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like Applied Consistently
&lt;/h2&gt;

&lt;p&gt;A team that runs all seven checks on every AI-assisted pull request, without exceptions for changes that look small, ends up with a noticeably different failure profile than a team that applies these checks selectively based on how risky a change seems at a glance. The selective approach fails specifically because risk is hard to judge accurately from a summary alone, and the changes that turn out to matter most are rarely the ones that looked dangerous going in.&lt;/p&gt;

&lt;p&gt;Consistency also has a second-order benefit: it makes the review process predictable for everyone on the team, including new hires who haven't yet developed the instinct for which changes deserve extra scrutiny. A fixed checklist applied every time removes the need for that instinct to exist at all, which is a more reliable foundation than hoping everyone's judgment calibrates the same way.&lt;/p&gt;

&lt;p&gt;Not a tool exactly, but worth including: require a one-line note in the pull request description explaining why the change was made, written by the human who reviewed it, not copied from the assistant's own summary. Six months later, this is the difference between understanding a change and reverse-engineering it from the diff alone.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The audit habit that actually sticks is the boring one: read the full diff before you read the summary, every single time, no exceptions for small changes." - Dennis Traina, &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;founder of 137Foundry&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;None of these tools require the assistant to be trustworthy on its own. That's the point. A codebase with good structure, documented conventions, and a real audit habit gets more reliable output from AI coding assistants precisely because nobody is relying on the assistant to police itself.&lt;/p&gt;

&lt;p&gt;We cover the codebase structure side of this, the files and conventions that reduce how often you need to reach for these audit tools in the first place, in &lt;a href="https://137foundry.com/articles/how-to-structure-codebase-ai-coding-assistants-stop-guessing" rel="noopener noreferrer"&gt;our guide to structuring a codebase so AI coding assistants stop guessing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;137Foundry helps engineering teams set up both sides of this: the conventions that prevent bad output and the review process that catches what slips through anyway.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>What Actually Happens When You Paginate a Live, Growing Table</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sat, 11 Jul 2026 11:29:55 +0000</pubDate>
      <link>https://dev.to/137foundry/what-actually-happens-when-you-paginate-a-live-growing-table-22in</link>
      <guid>https://dev.to/137foundry/what-actually-happens-when-you-paginate-a-live-growing-table-22in</guid>
      <description>&lt;p&gt;Pagination bugs are some of the hardest to catch in code review, because the code looks correct. It passes tests. It works perfectly in staging, where the data is static and nobody is writing to the table while QA clicks through pages. Then it ships, and somewhere in production a support agent notices a customer record that keeps disappearing from page 2 and reappearing on page 3 an hour later. Nobody touched the record. The table just kept growing while someone was scrolling through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model that causes the bug
&lt;/h2&gt;

&lt;p&gt;Most developers think of pagination as slicing a fixed list: rows 1 through 25, then 26 through 50, and so on, the same way you'd paginate a static array in memory. That model is accurate for an array that isn't changing. It's inaccurate for a database table under active writes, because the "list" isn't fixed at all. New rows are being inserted, and sometimes deleted, between the moment a client requests page 1 and the moment it requests page 2, and the pagination code has no idea any of that happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete walkthrough
&lt;/h2&gt;

&lt;p&gt;Say a table is sorted by &lt;code&gt;created_at DESC&lt;/code&gt;, newest first, and a client is paging through it with &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;LIMIT&lt;/code&gt;. They fetch page 1: rows 1 to 25, the 25 most recent records at that moment. Before they request page 2, three new rows get inserted somewhere else in the system, a normal amount of write traffic for any active application. Those three new rows are now the newest records, which pushes everything else down by three positions in the sort order.&lt;/p&gt;

&lt;p&gt;When the client requests page 2 with &lt;code&gt;OFFSET 25 LIMIT 25&lt;/code&gt;, the database doesn't know or care that three new rows showed up in the meantime. It just skips the first 25 rows of the &lt;em&gt;current&lt;/em&gt; result set and returns the next 25. But the current result set has shifted underneath the client. The three rows that used to sit at positions 23, 24, and 25 (visible on page 1) are now at positions 26, 27, and 28, which means they get skipped entirely and never shown to the client on either page. Three real records silently vanish from the user's view, and there's no error, no warning, nothing in the logs to flag it. The pagination "worked" exactly as designed. It just wasn't designed for a moving target, and that distinction matters enormously once real traffic hits it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than it sounds
&lt;/h2&gt;

&lt;p&gt;This isn't a rare edge case that only matters at extreme scale. Any table with regular writes, orders, support tickets, activity logs, comments, notifications, is affected the moment two users are browsing the same paginated list at the same time as writes are happening anywhere in that table. On a high-traffic application, that's not an edge case at all. That's every page load, every single day, quietly producing slightly wrong results that almost nobody notices until a customer complains about a specific missing record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop asking "skip N," start asking "after this point"
&lt;/h2&gt;

&lt;p&gt;Cursor-based (also called keyset) pagination replaces the offset with a pointer to a specific row, built from the sort column plus a unique tiebreaker like the primary key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;last_created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;last_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This query doesn't care how many rows exist before the cursor position. It doesn't skip anything at all. It seeks directly to the row identified by the cursor and reads forward. New inserts elsewhere in the table don't shift this query's results, because the query isn't defined in terms of position, it's defined in terms of a specific row's sort key, which stays valid no matter how many other rows get added or removed around it. The &lt;a href="https://www.postgresql.org/" rel="noopener noreferrer"&gt;PostgreSQL project's official site&lt;/a&gt; documents how this seek pattern uses a composite index far more efficiently than an offset-based scan, if you want to verify the mechanism yourself with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; against your own schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tiebreaker is not optional
&lt;/h2&gt;

&lt;p&gt;A cursor built from &lt;code&gt;created_at&lt;/code&gt; alone breaks the instant two rows share a timestamp, which happens constantly at millisecond precision under real concurrency, especially on bulk-import or batch-write workloads where many rows land in the same transaction. Without a unique tiebreaker, ties get resolved arbitrarily by the database's internal storage order, which reintroduces the exact skip-or-duplicate bug cursor pagination is supposed to fix in the first place. Always pair the sort column with a unique column, almost always the primary key, so the ordering is strictly deterministic regardless of how many rows share the same timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing for this specific failure mode
&lt;/h2&gt;

&lt;p&gt;Static test fixtures won't catch this, because the bug only exists under concurrent writes. Write a test that inserts new rows between page requests and asserts every row from page 1 and page 2 combined is unique, no repeats, no gaps compared to what was actually in the table. That's the one test that actually exercises the bug this whole class of pagination problem produces, and it's cheap to write once you know what you're testing for. Tools like &lt;a href="https://docs.pytest.org/" rel="noopener noreferrer"&gt;pytest&lt;/a&gt; make this straightforward to set up as a repeatable integration test against a real database instance rather than a mock, and for JavaScript backends, &lt;a href="https://jestjs.io/" rel="noopener noreferrer"&gt;Jest&lt;/a&gt; covers the same kind of setup against a real test database.&lt;/p&gt;

&lt;h2&gt;
  
  
  A variant of the same bug: deletions instead of insertions
&lt;/h2&gt;

&lt;p&gt;Everything above focuses on insertions shifting the result set, but deletions cause a mirror-image version of the same problem. If a row gets deleted from earlier in the sort order while a client is mid-pagination, offset pagination will skip one row too many on the next request, since the position it's counting from has shifted backward instead of forward. The client silently misses whatever row would have been at that boundary. This is common on tables where rows get soft-deleted or archived out of the default query scope while users are actively browsing, like a task list where completed items get filtered out as they're marked done in real time by other users.&lt;/p&gt;

&lt;p&gt;Cursor pagination handles this case the same way it handles insertions: because the cursor identifies a specific row's sort key rather than a numeric position, a deletion elsewhere in the table simply doesn't affect where the next query resumes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;This bug is a good example of why "it passed all the tests" and "it's correct" are not the same claim. The tests that existed were testing the wrong thing: static correctness on a snapshot, not correctness under the conditions the code will actually run in. Any pagination implementation destined for a table with regular writes needs a test that simulates those writes happening mid-pagination, or the bug simply won't surface until a real user hits it in production.&lt;/p&gt;

&lt;p&gt;We cover the full implementation, including cursor encoding, backward pagination, and the index you need to make it fast, in a longer guide over on &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137foundry.com&lt;/a&gt;. Worth a read before your next list endpoint ships against a table that's going to keep growing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist before you ship any paginated endpoint
&lt;/h2&gt;

&lt;p&gt;Before merging a new list endpoint, it's worth running through a short checklist rather than trusting that "it returned the right rows in my manual test" is sufficient proof of correctness. Confirm the sort clause includes a unique tiebreaker alongside the primary sort column, not just the primary sort column alone. Confirm there's a composite index backing that exact sort order, verified with an actual query plan rather than assumed from the schema. Confirm a test exists that inserts or deletes rows mid-pagination and checks for duplicates or gaps across page boundaries. And confirm the API documentation, whether internal or public, states plainly whether the endpoint uses offset or cursor pagination, since client code that assumes the wrong one will misbehave in ways that are hard to trace back to the actual cause.&lt;/p&gt;

&lt;p&gt;None of these four checks take more than a few minutes once you know to look for them, and together they cover almost every version of this bug we've seen show up in real applications. The alternative, discovering the problem after a customer reports vanishing data, costs a lot more than a few minutes, both in engineering time spent tracing the cause and in the trust cost of a customer wondering what else might be wrong with a system they're relying on. Building the check into a pull request template or a pre-merge checklist is a cheap way to make sure it doesn't depend on any one engineer remembering to think about it every time on every new endpoint.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>database</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Build API Pagination That Survives Rate Limits and Client Retries</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sat, 11 Jul 2026 11:28:04 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-build-api-pagination-that-survives-rate-limits-and-client-retries-188p</link>
      <guid>https://dev.to/137foundry/how-to-build-api-pagination-that-survives-rate-limits-and-client-retries-188p</guid>
      <description>&lt;p&gt;A pagination implementation that works fine in a demo can fall apart the moment a real client starts retrying failed requests, backing off, and resuming where it left off. If your cursor or page token isn't resilient to retries, you end up with duplicate processing, skipped records, or client-side bugs that only show up under real production network conditions, usually discovered by whichever customer has the flakiest network connection. Here's how to build it so it holds up under exactly those conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Make the cursor idempotent, not session-based
&lt;/h2&gt;

&lt;p&gt;The most common mistake is tying a pagination cursor to server-side session state, like an in-memory query result cached against a session ID or a temporary server-side result set. The moment a client retries after a timeout, or a load balancer routes the retry to a different server instance than the one that generated the cursor, that session state is gone and the request fails or returns garbage instead of the expected next page.&lt;/p&gt;

&lt;p&gt;Instead, make the cursor fully self-describing. Encode everything needed to resume the query directly in the cursor itself: the sort column value and a unique tiebreaker, typically the primary key.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encode_cursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sort_value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sort_value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sort_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any server instance, at any time, can decode this cursor and resume the exact same query from the exact same point. No session state required anywhere in the stack, which also makes horizontal scaling of your API servers trivial since no instance needs to remember anything about a client's previous requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Return rate limit headers on every paginated response
&lt;/h2&gt;

&lt;p&gt;If your API enforces rate limits, and most public APIs handling meaningful traffic should, include standard headers so clients can back off intelligently instead of guessing when it's safe to retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-RateLimit-Limit: 100
X-RateLimit-Remaining: 43
X-RateLimit-Reset: 1720713600
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no single universal standard for these exact header names, but this pattern is common enough across major APIs that most client libraries already know how to read it out of the box. Document your exact header names clearly in your API reference, since a client that can't parse your rate limit headers will retry blindly on a fixed interval and make the underlying problem worse rather than better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Make retries safe by design, not by convention
&lt;/h2&gt;

&lt;p&gt;A client that times out waiting for page 3 and retries with the exact same cursor should get the exact same page 3 back, not a different set of rows. Because cursor-based pagination is deterministic (same cursor, same query, same result, assuming no underlying data changed in the meantime), this is naturally idempotent as long as you're not accidentally injecting randomness like &lt;code&gt;ORDER BY RANDOM()&lt;/code&gt; or a non-deterministic tiebreaker somewhere in the query. Double-check your sort clause always includes a unique column so ties resolve the same way on every single execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Implement exponential backoff on the client side
&lt;/h2&gt;

&lt;p&gt;If you're consuming a paginated API rather than building one, implement backoff with jitter rather than fixed-interval retries, especially when pulling large datasets across many sequential pages:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_with_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch_fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;RateLimitError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Max retries exceeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jitter matters because without it, many clients hitting a rate limit at the same moment will all retry in lockstep, creating a thundering herd effect that makes the rate limit problem measurably worse instead of resolving it. The Python &lt;a href="https://requests.readthedocs.io/" rel="noopener noreferrer"&gt;requests library documentation&lt;/a&gt; covers session-level retry configuration if you'd rather not hand-roll this logic yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Set a reasonable page size ceiling to reduce total request volume
&lt;/h2&gt;

&lt;p&gt;Smaller default page sizes mean more total requests needed to page through a large dataset, which means more chances to hit a rate limit somewhere in the middle of a long pull. If you know a client is doing a bulk export or a full data sync, a larger page size, within your API's hard maximum, reduces both the number of round trips and the number of opportunities for a transient failure to interrupt the process partway through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Log cursor state on failure for debugging and safe resumption
&lt;/h2&gt;

&lt;p&gt;When a paginated pull fails partway through, log the last successful cursor so the operation can resume from that exact point rather than restarting from page 1. This matters enormously for large data syncs where restarting from scratch could mean re-processing millions of already-handled rows, which is both slow and risks duplicate side effects if the downstream processing isn't itself idempotent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Version the cursor format so future changes don't break old clients
&lt;/h2&gt;

&lt;p&gt;Once your API has real clients holding onto cursors, whether that's a bookmarked "load more" position or a long-running batch job resuming after a failure, changing the cursor's internal shape can silently break anyone with an old cursor still in hand. Include a version marker in the encoded payload so your decoder can detect an outdated format and respond with a clear, actionable error rather than a confusing failure deep in a query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decode_cursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unsupported cursor version, please request a fresh page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a small amount of upfront work that saves a confusing debugging session later, whenever the schema underneath the cursor inevitably needs to change to support a new sort option or filter combination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Handle partial failures in bulk consumers gracefully
&lt;/h2&gt;

&lt;p&gt;If a client is using pagination to pull a large dataset for a batch job, sync, or export, design the consumer so a failure on page 400 doesn't discard the 399 pages already successfully processed. This sounds obvious written out, but it's a surprisingly common failure mode in hastily written sync scripts that wrap the entire pagination loop in a single try/except block and throw away all progress on any exception, including transient ones that a retry would have resolved cleanly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sync_all_pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch_fn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_with_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fetch_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;log_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_cursor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processed_so_far&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
        &lt;span class="nf"&gt;process_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;has_more&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key detail is logging the last successful cursor before re-raising, which ties directly back into step 6 and gives whoever restarts the job a clean resumption point instead of a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;None of these six steps depend heavily on each other, so they can be implemented incrementally and tested one at a time. The highest-leverage change, by far, is step 1: making the cursor self-describing rather than session-dependent. Everything else builds resilience on top of that foundation, and none of it matters much if the cursor itself can't survive a request landing on a different server instance. If your API's pagination breaks under retries today, that's almost always where the root cause actually lives, not in the retry logic itself.&lt;/p&gt;

&lt;p&gt;For teams building or auditing API infrastructure like this, &lt;a href="https://137foundry.com/services/web-development" rel="noopener noreferrer"&gt;137Foundry's web development service&lt;/a&gt; covers exactly this kind of backend design work, from initial architecture through production hardening and load testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/" rel="noopener noreferrer"&gt;PostgreSQL official site&lt;/a&gt; for query planning and index behavior behind cursor pagination&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://owasp.org/www-project-api-security/" rel="noopener noreferrer"&gt;OWASP API Security&lt;/a&gt; for rate limiting and abuse-prevention patterns worth pairing with pagination hardening&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jsonapi.org/" rel="noopener noreferrer"&gt;JSON:API&lt;/a&gt; for a standardized approach to pagination response shapes and metadata&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>backend</category>
      <category>programming</category>
    </item>
    <item>
      <title>Seven Free Tools for Sandboxing What an AI Coding Assistant Can Execute</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Fri, 10 Jul 2026 09:27:11 +0000</pubDate>
      <link>https://dev.to/137foundry/seven-free-tools-for-sandboxing-what-an-ai-coding-assistant-can-execute-5ehg</link>
      <guid>https://dev.to/137foundry/seven-free-tools-for-sandboxing-what-an-ai-coding-assistant-can-execute-5ehg</guid>
      <description>&lt;p&gt;Giving an AI coding assistant somewhere safe to actually run things, not just read and suggest, is one of the better investments a team can make. A disposable environment turns "trust the assistant to be careful" into "it literally cannot reach anything that matters," which is a much sturdier guarantee. These seven tools cover most of what a small team needs to build that sandbox without a lot of infrastructure overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Docker
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.docker.com/" rel="noopener noreferrer"&gt;Docker&lt;/a&gt; is the baseline for isolating what a process, human or assistant driven, can reach. Run the assistant's shell inside a container with no network route to production and no mounted credentials, and an entire category of "wrong environment" incidents becomes structurally impossible rather than merely discouraged.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Docker Compose
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.docker.com/compose/" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt; makes disposable multi-service environments trivial to spin up and tear down. A scratch database, a scratch cache, and a scratch app server, seeded with synthetic fixtures, gives an assistant a real environment to test migrations against without any real data anywhere nearby.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Testcontainers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://testcontainers.com/" rel="noopener noreferrer"&gt;Testcontainers&lt;/a&gt; spins up throwaway, real instances of databases and services specifically for test runs, then tears them down automatically. It's a clean fit for letting an assistant apply and verify a migration against an actual Postgres or MySQL instance that exists for exactly as long as the test does.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SQLite
&lt;/h2&gt;

&lt;p&gt;For lighter-weight local iteration, &lt;a href="https://www.sqlite.org/" rel="noopener noreferrer"&gt;SQLite&lt;/a&gt; needs no server process at all, just a file, which makes it an easy default for an assistant's early draft-and-iterate loop before a migration gets tested against the real database engine your production stack actually uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. act
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/nektos/act" rel="noopener noreferrer"&gt;act&lt;/a&gt; runs your GitHub Actions workflows locally, which means an assistant, or a human, can validate a CI change without pushing to a branch and waiting on a real pipeline run. Useful for keeping assistant-proposed CI changes in a fast, disposable loop before they touch the shared pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Firecracker
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/firecracker-microvm/firecracker" rel="noopener noreferrer"&gt;Firecracker&lt;/a&gt; is the microVM technology behind several serverless sandboxing platforms, worth knowing about if you're building a more serious isolated execution environment for assistant-triggered code rather than relying on container isolation alone. It's a heavier lift to adopt directly, but it's the technology underneath a lot of the managed sandboxes teams reach for instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Faker libraries (Faker.js / Python Faker)
&lt;/h2&gt;

&lt;p&gt;Synthetic test data matters as much as environment isolation. &lt;a href="https://fakerjs.dev/" rel="noopener noreferrer"&gt;Faker&lt;/a&gt; and its Python equivalent generate realistic fake data so an assistant's sandbox has something meaningful to run against, without ever touching a copy of real customer records, which closes off the specific failure mode where "isolated" environments quietly get seeded from a production dump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing between these based on team size
&lt;/h2&gt;

&lt;p&gt;A two or three person team doesn't need Firecracker or a serious microVM setup, that's solving a problem at a scale you don't have yet. Docker Compose plus SQLite covers most early-stage sandboxing needs with almost no setup overhead, and you can add Testcontainers once your test suite is actually asserting against real database behavior rather than an in-memory approximation. Save the heavier infrastructure, Firecracker specifically, for when you're running many concurrent assistant sessions and need strong isolation guarantees between them, which is a later-stage problem for most teams, not a starting one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a minimal setup actually looks like end to end
&lt;/h2&gt;

&lt;p&gt;A reasonable starting configuration for a small team: a Compose file defining a scratch Postgres instance and the app itself, a seed script using Faker to populate that instance with realistic but synthetic data, and an assistant configuration that points every database-touching command at the Compose instance's connection string, never the real one. That's roughly a day of setup work for most stacks, and it closes off the majority of "assistant ran something against the wrong database" incidents structurally, rather than relying on the assistant, or a human, remembering to double check the connection string every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml, minimal sandboxing setup&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scratch_db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scratch&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local_only&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5433:5432"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# seed.py, using Faker to populate the scratch database with synthetic data
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;faker&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;

&lt;span class="n"&gt;fake&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://postgres:local_only@localhost:5433/scratch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO customers (name, email) VALUES (%s, %s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point every assistant-triggered database command at &lt;code&gt;localhost:5433&lt;/code&gt; during drafting and testing, and the question of whether it's safe to let the assistant run a migration stops being a trust question and becomes a fact about which connection string is configured, which is a much easier thing to get right consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extending the setup as the project grows
&lt;/h2&gt;

&lt;p&gt;The minimal setup above is a reasonable starting point, but it's worth planning for what changes as a project matures. A single scratch Postgres instance works fine for one assistant session at a time. Once multiple contributors, human or assistant-driven, are working in parallel, each needs its own isolated instance rather than sharing one scratch database where one session's test data pollutes another's results. Docker Compose profiles or a lightweight per-branch provisioning script handle this reasonably well without needing to jump straight to something like Firecracker.&lt;/p&gt;

&lt;p&gt;It's also worth thinking about how the synthetic seed data evolves alongside the real schema. A seed script written once at project start will drift from reality as new tables and columns get added over time. Treat updates to the seed script as part of the normal migration review process, not a separate maintenance task, so the sandbox stays representative of what the assistant will actually encounter when it drafts a new migration against the current schema rather than an outdated approximation of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on cost
&lt;/h2&gt;

&lt;p&gt;All seven tools listed here are free or have generous free tiers sufficient for small to mid-size teams, which matters because sandboxing shouldn't be the thing a team skips due to budget pressure. The actual cost of setting this up is engineering time, a day or two for the minimal version described above, not licensing fees. Weighed against the cost of even one incident involving an assistant running something against the wrong environment, that setup time is one of the better returns available in this entire guardrails conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes teams make when setting this up
&lt;/h2&gt;

&lt;p&gt;The most common mistake is seeding the sandbox from a production database dump instead of synthetic data, which defeats most of the point. A sandbox that contains real customer records is no longer a place where mistakes are free, it's just a second copy of the same risk you were trying to isolate. Faker or an equivalent synthetic data generator should be the default, and any exception, seeding from a scrubbed or anonymized production snapshot, should get the same scrutiny as granting access to production data anywhere else, because that's functionally what it is.&lt;/p&gt;

&lt;p&gt;The second common mistake is treating the sandbox as a one-time setup rather than something that needs to stay current with schema changes. A scratch database seeded once at project start, and never refreshed, drifts from the real schema within a few months, and an assistant testing migrations against a stale sandbox will confidently generate changes that conflict with columns or constraints added since. Rebuilding the sandbox's schema, even if the synthetic data itself doesn't need refreshing as often, should be part of your regular migration or schema-change workflow, not a separate maintenance task that quietly falls off the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring whether the sandbox is actually working
&lt;/h2&gt;

&lt;p&gt;A sandbox that never catches anything might mean your assistant is unusually careful, or it might mean the sandbox isn't representative enough of real conditions to surface the failure modes that matter. Track how often a migration or change gets modified after sandbox testing but before it reaches a human reviewer, versus how often something slips through sandbox testing and gets caught in human review instead. If the second number is consistently higher than the first, the sandbox isn't earning its keep, and it's worth investing more in making it representative, more realistic data volume, more accurate constraint definitions, closer parity with the real database engine version, rather than treating it as a checkbox that's already been handled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;None of these tools individually solves the sandboxing problem. Together, Docker or Compose for isolation, Testcontainers or SQLite for a real database to test against, act for CI validation, and Faker for data that doesn't carry real customer risk, they cover most of what a small team needs before ever letting an assistant apply anything to a shared environment.&lt;/p&gt;

&lt;p&gt;We cover the permission-tiering side of this problem, what an assistant should be allowed to execute once it has moved past the sandbox and into shared infrastructure, in &lt;a href="https://137foundry.com/articles/ai-coding-assistant-guardrails-database-migrations" rel="noopener noreferrer"&gt;137Foundry's guide to AI coding assistant guardrails&lt;/a&gt;. The sandbox is tier two of that framework; these seven tools are what build it.&lt;/p&gt;

&lt;p&gt;For teams setting this up from scratch, more on how we approach tooling and infrastructure decisions like this is at &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137foundry.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>What Actually Breaks When an AI Coding Assistant Has Too Much Shell Access</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Fri, 10 Jul 2026 09:26:30 +0000</pubDate>
      <link>https://dev.to/137foundry/what-actually-breaks-when-an-ai-coding-assistant-has-too-much-shell-access-lnl</link>
      <guid>https://dev.to/137foundry/what-actually-breaks-when-an-ai-coding-assistant-has-too-much-shell-access-lnl</guid>
      <description>&lt;p&gt;"Shell access" sounds like one permission, but in practice it's a bundle of very different capabilities that teams tend to grant all at once because splitting them feels like extra setup work. That bundling is where things go wrong, not because AI assistants are reckless, but because a single broad grant makes it hard to reason about what could actually happen when the assistant does something you didn't expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bundle nobody unbundles
&lt;/h2&gt;

&lt;p&gt;A typical "give the assistant shell access" setup lets it run tests, install dependencies, read files, write files, and execute arbitrary commands, all under the same permission. Each of those individually is low risk. Combined, they add up to something closer to "this process can do anything a developer with your credentials could do," which is a much bigger grant than most teams intend when they check the box.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# what "shell access" usually actually means in practice&lt;/span&gt;
npm &lt;span class="nb"&gt;test&lt;/span&gt;              &lt;span class="c"&gt;# low risk&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &amp;lt;pkg&amp;gt;     &lt;span class="c"&gt;# medium risk: supply chain exposure&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ./tmp           &lt;span class="c"&gt;# fine, until the assistant's idea of "tmp" differs from yours&lt;/span&gt;
psql &lt;span class="nt"&gt;-f&lt;/span&gt; migration.sql  &lt;span class="c"&gt;# high risk: no undo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix isn't revoking shell access entirely, it's granting the specific commands a workflow actually needs instead of a blanket shell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the actual incidents come from
&lt;/h2&gt;

&lt;p&gt;Most real incidents don't come from the assistant doing something it wasn't asked to do. They come from the assistant correctly executing a command whose scope was broader than the human intended. Someone asks it to "clean up temp files" and it interprets "temp" more broadly than expected. Someone asks it to "reset the local database" and the connection string in the environment pointed somewhere other than local, because an environment variable got left over from an earlier session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# a pattern that prevents the second failure mode above
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_local_db&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;db_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;db_url&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;db_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Refusing to run against non-local database: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;db_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-line guard like this, run before any destructive local command, catches an entire category of "wrong environment" incidents regardless of whether a human or an assistant issued the command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping shell access by task, not by tool
&lt;/h2&gt;

&lt;p&gt;Rather than one broad shell grant, define specific allowed commands per task type. A test-running task gets &lt;code&gt;npm test&lt;/code&gt; and nothing else. A dependency-update task gets &lt;code&gt;npm install&lt;/code&gt; scoped to a lockfile diff a human reviews after. A migration-drafting task gets read access to the schema and write access to a migration file, but never execute access against anything but a disposable local database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# example: scoping assistant permissions by task type&lt;/span&gt;
&lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;run_tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allowed_commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;test"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lint"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;draft_migration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allowed_commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migrate:generate"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;forbidden_commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migrate:apply"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;update_deps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allowed_commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;install"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;requires_human_review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is more setup than granting one shell and calling it done, but it converts "trust the assistant to behave" into "the assistant physically cannot do the thing we don't want," which is a much sturdier guarantee. Sandboxing tools like &lt;a href="https://www.docker.com/" rel="noopener noreferrer"&gt;Docker&lt;/a&gt; make this concrete: run the assistant's shell inside a container with no route to production credentials, and the scoping problem partly solves itself at the infrastructure layer instead of relying entirely on configuration discipline. Orchestration platforms like &lt;a href="https://kubernetes.io/" rel="noopener noreferrer"&gt;Kubernetes&lt;/a&gt; extend this further for teams running assistant sandboxes at scale, with network policies that can enforce the isolation boundary at the cluster level rather than trusting every container to be configured correctly by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why scoping by task beats scoping by tool
&lt;/h2&gt;

&lt;p&gt;A common instinct is to scope permissions per tool, this assistant gets shell, this other integration gets API access, and call it done. That misses the actual risk surface, because the same tool used for two different tasks can have wildly different blast radius. A shell that can run &lt;code&gt;npm test&lt;/code&gt; is fine for a hundred different task types. A shell that can also run &lt;code&gt;npm run migrate:apply&lt;/code&gt; is not fine for almost any task type except the one specifically designed to apply reviewed migrations through a gated path. Scoping by tool treats these as the same grant. Scoping by task treats them correctly as different ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# a task-scoped config catches what a tool-scoped one misses&lt;/span&gt;
&lt;span class="c1"&gt;# BAD: one shell grant covers everything&lt;/span&gt;
&lt;span class="na"&gt;assistant_permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# now it can run npm test AND npm run migrate:apply&lt;/span&gt;

&lt;span class="c1"&gt;# BETTER: permissions attach to the task, not the tool&lt;/span&gt;
&lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fix_failing_test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shell_commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;test"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lint"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;draft_schema_change&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shell_commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migrate:generate"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;forbidden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migrate:apply"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The credential leakage problem that's easy to miss
&lt;/h2&gt;

&lt;p&gt;Even with commands properly scoped, a shell session often carries environment variables far broader than the task needs, database URLs for every environment, API keys for services the current task never touches, cloud credentials with permissions well beyond what a test run requires. An assistant with shell access to run tests, but whose environment happens to include a production database URL as an unused variable, has effectively been granted access to that database the moment any command in its session could reference that variable, intentionally or not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# strip unrelated credentials before handing off a shell session
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;ALLOWED_FOR_TEST_RUN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NODE_ENV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL_TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scoped_env&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED_FOR_TEST_RUN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a small amount of extra plumbing that closes off an entire class of "the assistant technically had access" incidents that never show up in an audit of granted permissions, because the permission was never explicitly granted, it was just sitting in an environment nobody scoped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration case specifically
&lt;/h2&gt;

&lt;p&gt;Database migrations are the sharpest example of why bundled shell access is dangerous, because they're one of the few operations in a typical stack that isn't cleanly reversible by reverting a commit. Reference material on the database side matters too. The &lt;a href="https://www.postgresql.org/docs/" rel="noopener noreferrer"&gt;PostgreSQL documentation&lt;/a&gt; covers exactly which DDL operations are transactional and reversible within a session, knowledge that should inform where you draw the disposable-versus-shared line for any given migration. We wrote a full breakdown of how to tier assistant permissions specifically around migrations, including a concrete pattern for keeping draft and apply as separate, separately-gated actions, in &lt;a href="https://137foundry.com/articles/ai-coding-assistant-guardrails-database-migrations" rel="noopener noreferrer"&gt;our guide to AI coding assistant guardrails for database migrations&lt;/a&gt;. The scoping principle there generalizes to shell access broadly: separate what the assistant can propose from what it can execute, and gate the second one on a human every time the action isn't trivially reversible.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist worth running before granting shell access at all
&lt;/h2&gt;

&lt;p&gt;Before wiring an AI coding assistant into any shell environment, walk through a short list: what specific commands does this task actually need, not what commands might be convenient to have available. Does the environment the shell runs in contain any credential broader than the current task requires. Is there a disposable version of whatever this command would touch, and if so, is the assistant actually pointed at that disposable version by default rather than by careful configuration someone has to remember every time. Is there a human or CI gate between "this command ran successfully" and "this change is live somewhere that matters."&lt;/p&gt;

&lt;p&gt;None of these questions require sophisticated tooling to answer, they require someone to actually sit down and answer them before the first grant happens, rather than retroactively after an incident forces the question. Teams that do this upfront tend to end up with narrower, more specific shell configurations than teams that grant broadly and plan to tighten later, mostly because tightening later requires admitting the original grant was too broad, which is a harder conversation to have than getting the scope right from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth the setup time
&lt;/h2&gt;

&lt;p&gt;Scoped shell access takes longer to configure than a single broad grant, there's no getting around that. But the alternative cost isn't zero, it's deferred and often invisible until the specific command that shouldn't have been possible actually runs. A well-scoped configuration, once built, is also reusable across projects and easier to reason about during a security review, since "here's exactly what this assistant can execute and why" is a much stronger answer than "it has shell access and we trust it," even when the assistant has, so far, behaved exactly as expected.&lt;/p&gt;

&lt;p&gt;More on how we approach this kind of tooling setup at &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137foundry.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Write a Data Export Script That Survives a Vendor Migration</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Wed, 08 Jul 2026 09:17:46 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-write-a-data-export-script-that-survives-a-vendor-migration-22an</link>
      <guid>https://dev.to/137foundry/how-to-write-a-data-export-script-that-survives-a-vendor-migration-22an</guid>
      <description>&lt;p&gt;A vendor's built-in export button is designed to satisfy a support ticket, not to power a migration. If you actually need your data portable, the safer move is owning a small export script yourself, one that runs on a schedule and gives you a known-good copy independent of whatever the vendor's UI decides to include.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://requests.readthedocs.io/" rel="noopener noreferrer"&gt;Requests&lt;/a&gt; library is the natural starting point for this in Python, since most vendor APIs are plain REST over HTTPS and Requests handles the pagination and retry patterns below with minimal boilerplate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Identify the real source of truth
&lt;/h2&gt;

&lt;p&gt;Before writing anything, map which objects actually matter. Not every table in a vendor's schema is worth exporting, but skipping the wrong one means discovering a gap mid-migration. Prioritize anything with relational structure (deals linked to contacts, tickets linked to accounts) since that structure is what most built-in export buttons flatten away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Pull via the documented API, not the UI export
&lt;/h2&gt;

&lt;p&gt;Most SaaS platforms expose a REST API with pagination and rate limits. Use that instead of the UI export feature, because API responses typically preserve IDs and relationships that a CSV export drops.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;export_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# respect rate limits
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Preserve relationships, not just fields
&lt;/h2&gt;

&lt;p&gt;Store foreign keys alongside the records they reference, even if the new system will eventually need different IDs. You can remap IDs during a real migration. You can't reconstruct a relationship that was never captured in the first place. If you're storing exports as plain files rather than a database, a format like &lt;a href="https://jsonlines.org/" rel="noopener noreferrer"&gt;JSON Lines&lt;/a&gt; keeps each record independently parseable, which makes partial recovery much easier if a later export run gets interrupted partway through.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;export_with_relationships&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;contact_lookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;enriched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;deal&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;deals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;contact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contact_lookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;enriched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;deal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enriched&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kind of denormalization at export time costs almost nothing computationally and saves enormous effort later, since you won't need the original vendor's system alive just to resolve a foreign key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Run it on a schedule, before you need it
&lt;/h2&gt;

&lt;p&gt;Set this script up as a weekly or monthly job, storing exports somewhere durable like object storage. The value isn't the export itself, it's having a recent, known-good copy on the day you actually decide to leave, instead of racing a support ticket during a contract deadline.&lt;/p&gt;

&lt;p&gt;A simple cron entry or scheduled cloud function is enough for most teams. The frequency matters less than the consistency. A monthly export you can trust beats a "we'll figure it out when we need it" plan every time, because the day you actually need it is rarely a day when the vendor relationship is going well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Validate the export by reconstructing a record
&lt;/h2&gt;

&lt;p&gt;Periodically pick one real record and try to fully reconstruct it from your export alone. If you can't, your export is missing something, and it's much cheaper to find that gap during a routine check than during an actual migration crunch.&lt;/p&gt;

&lt;p&gt;Automate this validation where you can. A script that spot-checks a handful of exported records against the live system's API, comparing field-by-field, will catch schema drift long before a human reviewer would notice the export quietly stopped capturing a field the vendor added last quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Keep the schema documented alongside the export itself
&lt;/h2&gt;

&lt;p&gt;The export script's own output format needs documentation too, or you're just relocating the same problem one layer down. A README next to the export bucket describing what each field means, which fields are foreign keys, and which fields changed meaning over time, turns a pile of JSON files into something an unfamiliar engineer could actually use during a real migration six months from now, possibly without you in the room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Handle pagination and rate limits like they're permanent, not incidental
&lt;/h2&gt;

&lt;p&gt;It's tempting to write a quick script that assumes the API will always return everything in a single page, especially when your dataset is small today. Don't. Datasets grow, and a script that silently truncates results once you cross a pagination threshold is worse than no script at all, because it creates false confidence in an export that's actually incomplete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;export_all_paginated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;all_items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;raise&lt;/span&gt;
                &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;all_items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;all_items&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Building in retries and exponential backoff from the start means the script keeps working reliably as your data volume grows, rather than needing a rewrite the first time it hits a rate limit mid-export.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Store more history than you think you need
&lt;/h2&gt;

&lt;p&gt;Keep several months of prior exports, not just the latest one. If you discover a data quality issue, whether it's a vendor bug or something in your own script, having historical exports lets you pinpoint when the problem started rather than only knowing it exists in the current snapshot. Object storage is cheap enough that this is rarely a meaningful cost tradeoff against the diagnostic value it provides. A standard object store like &lt;a href="https://aws.amazon.com/s3/" rel="noopener noreferrer"&gt;Amazon S3&lt;/a&gt; or an equivalent from another cloud provider is more than sufficient for this, and versioned buckets give you the historical retention almost for free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1562575214-da9fcf59b907%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5MzI0MTZ8MHwxfHNlYXJjaHwzfHxmaWJlciUyMG9wdGljJTIwbGlnaHQlMjBzdHJhbmRzfGVufDF8fHx8MTc4MzQxNjIxM3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1562575214-da9fcf59b907%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5MzI0MTZ8MHwxfHNlYXJjaHwzfHxmaWJlciUyMG9wdGljJTIwbGlnaHQlMjBzdHJhbmRzfGVufDF8fHx8MTc4MzQxNjIxM3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="fiber optic cables glowing with light strands" width="1080" height="1626"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@umby?utm_source=137foundry&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Umberto&lt;/a&gt; on &lt;a href="https://unsplash.com?utm_source=137foundry&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth doing even if you never migrate
&lt;/h2&gt;

&lt;p&gt;Even teams with no near-term plans to switch vendors get real value from this pipeline. It becomes a natural audit trail, a disaster-recovery hedge if the vendor ever has a serious outage or data loss incident, and a source of truth you can query directly for analysis without hitting API rate limits against the live system. The migration-readiness benefit is real, but it's often the smallest of the three reasons teams end up glad they built this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: Monitor the export job itself, not just the vendor
&lt;/h2&gt;

&lt;p&gt;A scheduled export script that silently fails is arguably worse than no script at all, since it creates false confidence that a safety net exists when it actually stopped working months ago. Wire basic monitoring into the job: an alert if it fails to run, and a sanity check comparing the new export's record count against the previous run's count. A sudden, unexplained drop in record count is often the first sign something changed on the vendor's side, whether that's an API contract change, a permissions issue, or a genuine data loss event worth investigating immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 10: Treat schema changes on the vendor's side as expected, not exceptional
&lt;/h2&gt;

&lt;p&gt;Vendors change their APIs. Fields get renamed, deprecated, or restructured, usually with advance notice buried in a changelog nobody on your team reads regularly. Build your export script defensively, so a missing field logs a warning rather than crashing the entire job, and review those warnings periodically rather than only discovering a schema change when someone actually needs the missing data during a real migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;None of these ten steps require exotic tooling or a dedicated team. A single well-monitored script, running on a schedule, storing structured exports with preserved relationships in durable storage, gets most teams most of the portability benefit that otherwise depends entirely on trusting a vendor's own export feature to be complete. The upfront investment is measured in hours, not weeks, and it pays for itself the first time a migration decision needs to happen faster than a vendor's support queue can accommodate.&lt;/p&gt;

&lt;p&gt;This kind of proactive export discipline is exactly the countermeasure to the lock-in risk we break down in &lt;a href="https://137foundry.com/articles/evaluate-vendor-lock-in-risk-before-committing-saas-platform" rel="noopener noreferrer"&gt;our framework for evaluating vendor lock-in before you sign a SaaS contract&lt;/a&gt;. Data portability is one of four dimensions worth scoring, and owning your own export pipeline is the practical way to keep that score in your favor regardless of what the vendor's contract says.&lt;/p&gt;

&lt;p&gt;If you're building this kind of pipeline and want a second set of eyes on the schema design, that's the kind of &lt;a href="https://137foundry.com/services/data-integration" rel="noopener noreferrer"&gt;data integration&lt;/a&gt; work we do often.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Audit Your Own Integration Surface Before It Becomes a Lock-In Problem</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Wed, 08 Jul 2026 09:17:44 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-audit-your-own-integration-surface-before-it-becomes-a-lock-in-problem-5817</link>
      <guid>https://dev.to/137foundry/how-to-audit-your-own-integration-surface-before-it-becomes-a-lock-in-problem-5817</guid>
      <description>&lt;p&gt;Vendor lock-in doesn't usually happen because of one big decision. It accumulates from dozens of small ones: a webhook here, a Zapier automation there, a script someone wrote during an on-call incident that quietly became load-bearing. None of those individual choices look risky at the time. The aggregate is what hurts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1680691257251-5fead813b73e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5MzI0MTZ8MHwxfHNlYXJjaHwxfHxzZXJ2ZXIlMjByYWNrJTIwY2FibGVzJTIwb3JnYW5pemVkfGVufDF8fHx8MTc4MzUwMjI2Mnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1680691257251-5fead813b73e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5MzI0MTZ8MHwxfHNlYXJjaHwxfHxzZXJ2ZXIlMjByYWNrJTIwY2FibGVzJTIwb3JnYW5pemVkfGVufDF8fHx8MTc4MzUwMjI2Mnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="server rack cables organized neatly" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@dkfra19?utm_source=137foundry&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Dimitri Karastelev&lt;/a&gt; on &lt;a href="https://unsplash.com?utm_source=137foundry&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why nobody notices until it's expensive
&lt;/h2&gt;

&lt;p&gt;Each integration is built by whoever needed it, when they needed it, with no visibility into what else already depends on the same vendor. Six months later, that vendor is a single point of failure for five different workflows and nobody who built any single one of them realizes the total blast radius.&lt;/p&gt;

&lt;p&gt;The fix isn't stopping people from building integrations. It's making the total integration surface visible, on a cadence, so the dependency count is a known number instead of a surprise.&lt;/p&gt;

&lt;p&gt;This is the same failure mode that shows up in technical debt generally: no single decision looks unreasonable in isolation, but the aggregate becomes a genuine liability nobody explicitly chose. The difference with vendor integrations is that the debt isn't just harder-to-maintain code, it's negotiating leverage you've quietly handed to a vendor whose renewal pricing gets more aggressive every year you can't easily leave.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this matters more for smaller teams, not less
&lt;/h2&gt;

&lt;p&gt;There's an intuition that lock-in is mostly an enterprise problem, since large companies have more systems and more integration points. In practice, smaller teams are often more exposed, because they lack a platform or infrastructure team whose job is specifically to track this kind of dependency. A twenty-person startup can accumulate the same integration sprawl as a five-hundred-person company, just with fewer people around who'd notice it happening.&lt;/p&gt;
&lt;h2&gt;
  
  
  A simple audit you can run this week
&lt;/h2&gt;

&lt;p&gt;Start with the vendor's own developer console if it has one. Most platforms expose a list of active webhooks, API keys, and connected apps under account or security settings. That list is your starting inventory.&lt;/p&gt;

&lt;p&gt;Cross-reference it against your automation platform (&lt;a href="https://zapier.com/" rel="noopener noreferrer"&gt;Zapier&lt;/a&gt;, &lt;a href="https://www.make.com/" rel="noopener noreferrer"&gt;Make&lt;/a&gt;, &lt;a href="https://n8n.io/" rel="noopener noreferrer"&gt;n8n&lt;/a&gt;, or an internal script repo). For every integration you find, capture three things: what triggers it, what it does, and who would notice if it silently broke tomorrow. That third question is the one teams skip, and it's the one that matters most when you're planning a migration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# a minimal starting point for tracking integration dependencies
&lt;/span&gt;&lt;span class="n"&gt;integrations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme_crm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deal.won&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sync_to_billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme_crm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact.updated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update_mailing_list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;marketing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;blast_radius&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;integrations&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;vendor_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even a spreadsheet version of this is enough to start. The goal is just converting invisible dependency count into a visible one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the audit into an ongoing habit
&lt;/h2&gt;

&lt;p&gt;Run this audit before adopting any new tool that another system might eventually integrate with, and again roughly every quarter for vendors you already depend on heavily. Integration count only grows over time; catching it early is cheaper than discovering it during a forced migration.&lt;/p&gt;

&lt;p&gt;Assign an actual owner to this recurring audit, not just a shared responsibility that quietly belongs to nobody. Quarterly reviews that everyone agrees are important but nobody specifically owns tend to slip the first time a sprint gets busy, and the integration count that would've taken fifteen minutes to review in March becomes a much bigger surprise by September.&lt;/p&gt;

&lt;h2&gt;
  
  
  A more detailed inventory template
&lt;/h2&gt;

&lt;p&gt;Once you've got a first pass done, formalize it slightly. For each integration, capture the vendor, the trigger event, the downstream action, the business owner, and a rough severity if it silently broke: cosmetic, annoying, or genuinely business-critical. That severity column is what turns a flat list into something you can actually prioritize during a migration planning conversation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;integration_inventory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme_crm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deal.won&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sync_to_billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme_crm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact.updated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update_mailing_list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;marketing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annoying&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;critical_dependencies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;integration_inventory&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;vendor_name&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A vendor with two critical dependencies and six cosmetic ones is a very different migration project than a vendor with eight critical dependencies, even if the raw integration count looks similar on paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do once you know the number
&lt;/h2&gt;

&lt;p&gt;Finding out you have eighteen integrations against one vendor isn't itself a crisis. It's information. Some of those integrations might be trivial to replace, others might genuinely justify staying with the vendor even at a worse price, because the switching cost outweighs the savings. The point of the audit isn't to force a migration, it's to make sure that decision gets made deliberately, with real numbers, instead of by default because nobody had visibility into the total picture.&lt;/p&gt;

&lt;p&gt;Where this becomes genuinely actionable is at renewal time. A vendor that knows you've quietly become dependent on eighteen integrations negotiates very differently than one who knows you've tracked that number and have a documented plan for the ones that matter least. Visibility into your own integration surface is, in a very literal sense, negotiating leverage.&lt;/p&gt;

&lt;p&gt;We cover the broader risk framework, including data portability and contract exit terms alongside integration depth, in &lt;a href="https://137foundry.com/articles/evaluate-vendor-lock-in-risk-before-committing-saas-platform" rel="noopener noreferrer"&gt;our guide to evaluating vendor lock-in risk before committing to a platform&lt;/a&gt;. Integration surface is one of four dimensions worth scoring before you sign, not after.&lt;/p&gt;

&lt;p&gt;For teams standardizing how services talk to each other, the &lt;a href="https://www.openapis.org/" rel="noopener noreferrer"&gt;OpenAPI Initiative&lt;/a&gt; is worth a look, since documented, standard API contracts make future integration audits considerably less painful than reverse-engineering an undocumented one. The &lt;a href="https://zapier.com/" rel="noopener noreferrer"&gt;Zapier&lt;/a&gt; platform is also a reasonable place to centralize visibility if your team's automations are scattered across several no-code tools already, since it at least puts everything in one dashboard rather than five. More on how we approach this kind of work at &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137foundry.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pattern worth watching for specifically
&lt;/h2&gt;

&lt;p&gt;One integration type deserves extra scrutiny during the audit: anything where a vendor's data flows into a system of record, meaning a place where the data becomes the canonical version rather than just a cached copy. A read-only dashboard pulling from a vendor's API is low risk, since you can rebuild the dashboard without touching the underlying data. A workflow where a vendor's webhook writes directly into your billing system or your customer database is a different category of risk entirely, because untangling that dependency means carefully verifying data integrity on both sides during the transition, not just rewiring a data source.&lt;/p&gt;

&lt;p&gt;Flag these system-of-record integrations distinctly in your audit. They're usually a small fraction of the total integration count but represent a disproportionate share of the real migration risk, and they're exactly the ones worth prioritizing if you ever do decide a vendor relationship needs to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the audit sustainable long-term
&lt;/h2&gt;

&lt;p&gt;The biggest risk to this whole practice isn't that teams disagree it's valuable, it's that the audit quietly stops happening after the first one or two rounds, once the initial enthusiasm fades and other priorities crowd it out. Tying it to an existing recurring ritual, a quarterly planning session or an existing infrastructure review, tends to work better than treating it as a standalone calendar reminder that's easy to snooze indefinitely. The goal is a habit that survives turnover on the team, not a one-time project that only exists because one person happened to care about it this quarter.&lt;/p&gt;

</description>
      <category>business</category>
      <category>api</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Faceted Navigation Is the Silent Killer of Crawl Budget on Ecommerce Sites</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Tue, 07 Jul 2026 09:34:54 +0000</pubDate>
      <link>https://dev.to/137foundry/why-faceted-navigation-is-the-silent-killer-of-crawl-budget-on-ecommerce-sites-5fod</link>
      <guid>https://dev.to/137foundry/why-faceted-navigation-is-the-silent-killer-of-crawl-budget-on-ecommerce-sites-5fod</guid>
      <description>&lt;p&gt;Faceted navigation is a genuinely good user experience feature. Letting shoppers filter by size, color, brand, and price at the same time is table stakes for any catalog with more than a few dozen products. It's also one of the most reliable ways to accidentally generate tens of thousands of crawlable URLs from a single category page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The math nobody does until it's a problem
&lt;/h2&gt;

&lt;p&gt;A category page with five facets, each offering four to six options, doesn't produce five to thirty variations. It produces every mathematically possible combination of selected filters, which climbs into the thousands fast. Multiply that across every category on the site, and a catalog with a few hundred real products can generate URLs numbering in the hundreds of thousands.&lt;/p&gt;

&lt;p&gt;Each of those is a request Googlebot might make. Crawl budget, the finite number of requests a search engine is willing to spend on your domain per day, isn't infinite, and every request spent on &lt;code&gt;?color=red&amp;amp;size=large&amp;amp;brand=x&lt;/code&gt; is a request not spent discovering or refreshing content that actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse for ecommerce specifically
&lt;/h2&gt;

&lt;p&gt;Product availability and pricing change constantly. If crawl budget is being consumed by filter permutations, Googlebot revisits your actual product pages less often, which means stale pricing and out-of-stock listings can linger in search results longer than they should. That's a direct hit to conversion rate from organic traffic, not just an abstract ranking concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture decision hiding inside this problem
&lt;/h2&gt;

&lt;p&gt;The real question isn't "how do we stop this," it's "which filter combinations are worth their own indexable page." A single-facet filter on a popular category, &lt;code&gt;?color=red&lt;/code&gt; on a shirts page, might represent real search demand worth capturing with its own optimized template. A four-facet combination almost certainly doesn't.&lt;/p&gt;

&lt;p&gt;This means the fix isn't purely technical. It requires someone to look at actual search query data and decide, facet by facet, what earns an indexable page versus what gets canonicalized away or blocked outright. Google's own documentation on &lt;a href="https://developers.google.com/search/docs/crawling-indexing" rel="noopener noreferrer"&gt;faceted navigation best practices&lt;/a&gt; covers the mechanics of the canonical and parameter handling options in detail, but the demand analysis is a judgment call specific to your catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pragmatic default for teams without time for the full analysis
&lt;/h2&gt;

&lt;p&gt;If you don't have the bandwidth to run a full search-demand study per facet, a reasonable default is: index single-facet category and subcategory pages if they get meaningful search volume, canonicalize everything else back to the base category page, and block pure tracking or session parameters at the robots.txt level. It's not perfect, but it stops the bleeding while a more deliberate strategy gets built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fixing it later costs more than fixing it now
&lt;/h2&gt;

&lt;p&gt;Parameter sprawl compounds. Every month a faceted navigation system runs without canonical tags or indexing rules, more of those combinations get discovered and indexed by search engines, and more of them accumulate whatever thin backlink or internal link signal happens to reach them by accident. Cleaning up a six-month-old mess is a bounded project. Cleaning up a four-year-old one on a catalog that's grown ten times larger in that time is a much bigger undertaking, both because there's more to classify and because some of those stray indexed pages will have picked up real search traffic that needs to be redirected carefully rather than just blocked.&lt;/p&gt;

&lt;p&gt;This is one of the clearer cases in technical SEO where the cost curve is genuinely exponential rather than linear. Catching it in the first few months after a faceted navigation feature ships is dramatically cheaper than catching it after the catalog has doubled twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signals that your faceted navigation specifically is the culprit
&lt;/h2&gt;

&lt;p&gt;Not every duplicate content problem traces back to facets, so it's worth confirming before assuming that's the fix needed. Check whether the base paths showing the most parameter variance in your crawl or log data correspond to your filterable listing pages specifically, versus other sources like session tracking or CMS-generated print views. If the worst offenders are &lt;code&gt;/category/*&lt;/code&gt; or &lt;code&gt;/products/*&lt;/code&gt; style routes with multiple query parameters stacked together, faceted navigation is very likely the primary driver, and the fix belongs at the filter component level rather than scattered across individual page templates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engineering side people underestimate
&lt;/h2&gt;

&lt;p&gt;It's tempting to treat this as purely an SEO team's problem to flag and a frontend team's problem to fix in isolation, but the cleanest implementations come from the two working from the same source of truth about which facet combinations are canonical, indexable, or blocked. If SEO hands over a spreadsheet of rules and engineering implements them once without a shared mechanism for keeping them in sync, the rules drift the next time a facet gets added or renamed. A small config file or database table mapping facet combinations to their intended indexing treatment, referenced by both the sitemap generator and the canonical tag logic, tends to hold up much better over time than rules baked separately into multiple places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Framework and platform defaults are not a substitute for checking
&lt;/h2&gt;

&lt;p&gt;It's worth explicitly checking rather than assuming, even on platforms with a reputation for handling this well out of the box. A platform might correctly canonicalize single-facet filters by default while leaving multi-facet combinations entirely unhandled, which is exactly the gap that causes the most damage since multi-facet combinations are also the ones generating the largest URL counts. Read your specific platform or framework's documentation on faceted navigation and canonical handling directly rather than assuming "it's a mature platform, it must handle this," since defaults vary significantly and change between versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring whether the fix actually helped
&lt;/h2&gt;

&lt;p&gt;After deploying canonical tags and indexing rules across a faceted navigation system, the metric to watch isn't just the Search Console duplicate content count, though that should trend down. Crawl stats for your actual product and category pages, the ones that matter for conversions, should show increased crawl frequency over the following month as budget that was previously spent on facet combinations gets redirected toward pages Google now understands are worth revisiting more often. If that shift doesn't happen, the fix likely didn't cover enough of the parameter surface area, and it's worth re-running the log analysis to check for combinations that were missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;We walked through the full sequence, auditing scope with Search Console and log data, classifying parameters into content-changing versus non-content buckets, and applying canonical tags correctly, over at &lt;a href="https://137foundry.com/articles/url-parameters-duplicate-content-canonical-tags" rel="noopener noreferrer"&gt;137foundry.com&lt;/a&gt;. The ecommerce case is really just that same process applied at a larger scale, since faceted navigation is the single biggest parameter generator most catalog sites have.&lt;/p&gt;

&lt;p&gt;If you're evaluating platforms, &lt;a href="https://shopify.dev/" rel="noopener noreferrer"&gt;Shopify's own developer documentation&lt;/a&gt; and most major ecommerce platforms document how their filter systems handle canonical tags by default, and it's worth checking before assuming a fix is needed. Some platforms already handle single-facet canonicalization correctly out of the box and only fall short on multi-facet combinations. For a platform-agnostic reference on how crawlers evaluate faceted URLs generally, &lt;a href="https://developers.google.com/search/docs/crawling-indexing" rel="noopener noreferrer"&gt;Google's Search Central crawling and indexing documentation&lt;/a&gt; is the most current source, since platform vendor docs sometimes lag behind changes in how search engines actually treat these patterns.&lt;/p&gt;

&lt;p&gt;Getting the crawl budget spent in the right place is one of those problems that compounds quietly. A catalog that fixes this early scales cleanly. One that doesn't ends up doing a much larger cleanup years later once the parameter sprawl has had time to multiply.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How to Write a Script That Finds Every Parameterized URL Variant on Your Site</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Tue, 07 Jul 2026 09:33:40 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-write-a-script-that-finds-every-parameterized-url-variant-on-your-site-2n5n</link>
      <guid>https://dev.to/137foundry/how-to-write-a-script-that-finds-every-parameterized-url-variant-on-your-site-2n5n</guid>
      <description>&lt;p&gt;Before you can fix duplicate content caused by URL parameters, you need to know the actual scope. Guessing from a few examples in Search Console misses most of the problem. This walks through a small Python script that parses server logs and groups requests by their base path, so you can see exactly how many parameter variants each route is generating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why log parsing beats Search Console alone
&lt;/h2&gt;

&lt;p&gt;Search Console tells you which duplicate issues Google noticed, but it samples and aggregates, so smaller-volume parameter combinations don't always surface individually. Your own server logs (or a CDN's access logs) contain every request that actually happened, which gives you the real count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the parser
&lt;/h2&gt;

&lt;p&gt;This assumes a standard combined log format, common to Apache and nginx by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parse_qs&lt;/span&gt;

&lt;span class="n"&gt;LOG_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;(?:GET|POST) (?P&amp;lt;path&amp;gt;[^\s]+) HTTP&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_log_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LOG_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;group_by_base_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_file&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_log_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;
            &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_qs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;param_signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
                &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;param_signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reading the results
&lt;/h2&gt;

&lt;p&gt;Once you've grouped requests, sort by the number of distinct parameter signatures per base path to find your worst offenders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;group_by_base_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access.log&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;base_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signatures&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signatures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; distinct parameter combinations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A route showing forty or fifty distinct parameter combinations against a handful of actual product variations is a clear candidate for canonical tag cleanup, and probably a robots.txt rule for anything that's pure tracking noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extending it to check canonical tags directly
&lt;/h2&gt;

&lt;p&gt;Once you have a list of the worst offending base paths, cross-reference a sample of each parameter variant's actual canonical tag using a lightweight request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;canonical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;href&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this against ten or so parameter variants per base path and confirm they all resolve to the same canonical URL. Inconsistent results here point to a templating bug rather than a missing rule, which is a different fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtering out bot traffic that isn't Googlebot
&lt;/h2&gt;

&lt;p&gt;Not every request in your logs represents crawl budget you care about optimizing. Scrapers, monitoring services, and other bots hit the same URLs, and including them in your analysis inflates the apparent problem or, worse, hides the real Googlebot pattern underneath noise from unrelated traffic. Filter by user agent before counting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_googlebot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Googlebot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;group_googlebot_only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_file&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;is_googlebot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_log_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_qs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
                &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A simple user agent string match is a reasonable starting filter, though be aware that user agent strings can be spoofed. If you need to confirm requests are genuinely from Google's crawlers rather than something pretending to be, a reverse DNS lookup against the requesting IP, checked against Google's published IP ranges, is the more rigorous approach, but for a first-pass audit the string match is usually good enough to get directionally accurate numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling gzipped and rotated logs
&lt;/h2&gt;

&lt;p&gt;Most production setups rotate and compress logs after a day or two, so a real version of this script needs to handle &lt;code&gt;.gz&lt;/code&gt; files transparently:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;open_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.gz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap this into &lt;code&gt;group_by_base_path&lt;/code&gt; in place of the plain &lt;code&gt;open()&lt;/code&gt; call, and loop over every rotated file in your log directory rather than a single file, so you get a representative sample across at least a week rather than a few hours that might not reflect a weekly cron job or a scheduled crawl pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a simple CLI wrapper for repeated runs
&lt;/h2&gt;

&lt;p&gt;If you expect to run this audit more than once, wrapping it as a small command-line tool with &lt;a href="https://docs.python.org/3/library/argparse.html" rel="noopener noreferrer"&gt;argparse&lt;/a&gt; saves time on every subsequent run:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ArgumentParser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Find parameterized URL variants from access logs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;log_dir&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Directory containing log files (supports .gz)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--top&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Number of worst offenders to show&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--googlebot-only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;store_true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Filter to Googlebot requests only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_args&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# wire up group_by_base_path / group_googlebot_only based on args here
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Analyzing logs in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log_dir&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, showing top &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; offenders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This turns a one-off analysis script into something a whole team can rerun monthly as part of a standing technical SEO check, rather than something only one person remembers how to invoke correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exporting results for non-technical teammates
&lt;/h2&gt;

&lt;p&gt;The raw script output is fine for an engineer, but an SEO specialist or product manager on the same team will get more value from a CSV they can sort and filter themselves. Adding an export step takes a few lines:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;export_to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked_results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameter_audit.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;distinct_combinations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_requests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;base_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;base_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sharing this file alongside the raw numbers turns a script only one engineer can run into a shared artifact the whole team can reference when deciding which templates to prioritize fixing first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the output into a prioritized list
&lt;/h2&gt;

&lt;p&gt;Raw counts of parameter combinations are useful, but pairing them with actual crawl frequency data makes the prioritization sharper. Extend the script to also track how many total requests hit each base path, not just how many distinct combinations exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;group_with_counts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_file&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_log_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;
            &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_qs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can rank base paths not just by how many distinct combinations exist, but by total wasted request volume, which is the number that actually matters for crawl budget. A path with fifteen combinations getting hit constantly is a bigger problem than one with sixty combinations that barely get crawled at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits in the bigger fix
&lt;/h2&gt;

&lt;p&gt;This script gets you the diagnostic data. The actual remediation, classifying parameters and applying canonical tags correctly, is covered step by step in &lt;a href="https://137foundry.com/articles/url-parameters-duplicate-content-canonical-tags" rel="noopener noreferrer"&gt;137foundry.com's guide to stopping URL parameters from creating duplicate content&lt;/a&gt;. Running this kind of audit before touching any template code saves you from fixing the wrong ten pages while the real offenders keep piling up, since intuition about which routes are the worst offenders is frequently wrong until you actually measure it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.python.org/3/library/urllib.parse.html" rel="noopener noreferrer"&gt;Python &lt;code&gt;urllib.parse&lt;/code&gt; documentation&lt;/a&gt; covers the parsing utilities used here in more depth if you need to handle edge cases like array-style parameters or encoded query strings. If you're working with a larger log volume than fits comfortably in memory, the &lt;a href="https://pandas.pydata.org/docs/" rel="noopener noreferrer"&gt;pandas documentation&lt;/a&gt; covers chunked reading patterns that scale better than the dictionary-based approach shown here once you're past a few million log lines. For confirming which IP ranges genuinely belong to Google's crawlers before trusting a user-agent-based filter for anything beyond a rough first pass, &lt;a href="https://developers.google.com/search" rel="noopener noreferrer"&gt;Google's published crawler IP list&lt;/a&gt; documents the verification process directly.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>python</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
