There is no limit to how bad code can get
Starting from a short comment by Simon Willison, this piece walks through maintenance cost, tests, review, security debt, and working habits in the age of coding assistants.
Read the original paper
Patrick Rho · AI Research
This piece covers a short comment by Simon Willison called "There's No Limit to How Bad Code Can Get". Willison is a developer and writer known for long running work on web development and practical tools. The original is linked under the title. The claim of the comment can be put in one sentence: maintenance and debugging dominate the cost of a software life cycle, so code that is hard to read and has no tests ends up costing far more than whatever time was saved writing it quickly. This post follows that diagnosis through duplication and oversized objects, swallowed exceptions, missing tests, security debt, organizational incentives, and the new ways coding assistants change how mistakes happen.
Why bad code keeps piling up instead of getting fixed
What interests me more in this comment is the attitude toward direction rather than the list of bad patterns. Code does not stop at some level. Left alone, it drifts toward worse. Deadlines approach, requirements shift, urgent patches pile up, and the structure tilts a little. On top of a tilted structure the next change is harder, and harder changes tilt the structure further.
In daily work this flow is easy to miss. Each day looks fine. Features ship, the demo runs, no customer complaint explodes right away. The bill arrives months later. You try to change one small phrase and the same branching logic pops up in five places. You search the logs and find a spot where an exception quietly disappeared. The person who owned the area has moved teams, and the person left behind edits by guessing instead of reading.
I keep asking what "going fast" means here. It usually points at writing speed. Keys move fast and files multiply. But real speed in software comes from reading speed. How fast a newcomer understands the area, how narrow the blast radius of a change stays, and how much confidence remains after the edit decide the pace. Writing speed is a one day metric. Reading speed is a multi year metric.
When my team looks at a new AI architecture, we do not stare at benchmark numbers alone. We ask what actually changed and what cost structure that change creates in a system. I read code the same way. Line counts and commit counts matter less than how far a change spreads and how much effort checking it takes. From that angle, bad code is not just untidy style. It is a structure that raises the price of every later change.
How maintenance eats the time you thought you saved
Writing is short and maintenance is long in the life of software. A feature that took weeks to build gets touched a little every week for years. Requirements change, libraries change, platforms change, security advisories land. Each time, someone reads the code again. Code that is hard to read charges a tax at every visit.
The tax shows up in several forms. Understanding takes longer, edit ranges grow wider, and checking side effects forces wider test runs. Confidence drops, so deploys slow down and rollback plans get thicker. Small changes trigger long meetings. The question "is it safe to touch this" repeats. The repetition itself proves the code fails to explain itself.
Exact measurement is hard but the direction is clear. Hard to read code with no tests eats the early savings fast. Two days saved at the start turn into two extra days every month later. The interest compounds. As duplication grows, branches deepen, and dependencies tangle, the unit price of the next change rises. Higher prices push refactoring further out, and delayed refactoring pushes prices higher still.
The way out is not dramatic. Keep change ranges small, write for the next reader, and leave devices that prove behavior. The goal is simple: after I change one function, no spoken explanation should be needed. Tests do that job, types do that job, short reviews do that job. Give up a little convenience at writing time and collect it back with interest during maintenance.

What happens in code where copying keeps copying
Duplication is the most common starting point. Copy and paste under pressure looks reasonable. Leave the old path alone, add a new branch, dodge today's outage. Tomorrow pays for it. Copied code evolves separately. A bug fixed on one side stays broken on the other. The same logic starts behaving slightly differently in three places.
The scary part is not the bug itself but the cost of checking. The question "where else does this logic live" can only be answered by searching. Searching tires people, and tired people miss one spot. The missed spot returns months later as an incident report. Whoever gets the report searches the whole area again. The same work repeats.
The prescription sounds simple. Pull the varying part outward and keep the shared part in one place. Wrap it in a function, give it a name, make the boundary clear. Saying it is easy and judging it is hard. Extract too early and unrelated things get tied together. Extract too late and the logic has already spread to three places. I usually extract when I see the third copy. Two copies may be coincidence. Three copies mean the structure is speaking.
One caution belongs here. Removing duplication is not a cure all. Things that look alike but change for different reasons should stay separate. Group things that change together. If you group things that change apart, edit ranges grow instead of shrinking. When I look at duplication I watch rhythm more than shape. Code fixed together stays together. Code fixed apart stays apart.
When one object grows too big and branches run too deep
A god object is harder than small duplication. An object that knows everything shows up in every change. It knows orders, payments, notifications, settlements. At first it feels convenient because anything can pull from it. With time nothing can change without touching it.
The real price sits in comprehension range. You open the file to fix one method and the context spreads across dozens of fields and dozens of methods. Too much state to hold in your head makes confidence hard. Without confidence you add one more condition to stay safe. Conditions pile up and the next reader understands even less.
Deeply nested conditionals share the same root. An if inside an if, exception handling inside that, flag branches inside that. Reading such code uses memory more than eyes. By the third level the first condition blurs. That is where mistakes enter. Edits land in the wrong branch, unreachable code stays behind, order dependencies slip past review.
When I meet this shape I start by flattening branches. I return early to clear edge cases, pull conditions into named functions, and reshape data so state combinations shrink. Deep nesting signals that the code tries to answer too many questions at once. Split the questions and depth falls. Shallower code also accepts tests more easily.

The exceptions that vanish quietly and the secrets that get stuck in code
A swallowed exception is quiet debt. A catch block that is empty or logs one line and moves on does not explode today. So it stays for years. When an incident finally lands, symptom and cause sit far apart. A payment fails, logs show nothing, the user says it just did not work. Debugging turns into a treasure hunt.
The rule for exceptions stays simple. Recover where you can, report upward where you cannot, and record with context. An empty catch declares that something unknown will stay buried. There are moments when swallowing is briefly needed, for example when a noisy outside system must not halt the flow. Then keep the scope narrow, write down why, and set a date to revisit. The longer an exception stays buried, the larger the interest later.
Hard coded secrets are a different kind of risk. An API key or password baked into code feels handy, but once it enters history it escapes control. Scoping access gets hard, rotation gets hard, knowing who saw it gets hard. Rotating a key forces a redeploy, and the deploy opens fresh chances to leak. Secrets belong to environments and permission systems, not to source files.
Dependency handling sits on the same line. Your code includes more than what you typed. Libraries you pulled in, transitive dependencies, build plugins all run as code. Neglected dependencies keep known vulnerabilities in place. Delaying updates from fear makes the fear grow. The rhythm that works is to upgrade in small versions often, read the changelog, and back the move with tests. Pruning the list is also a skill. Fewer unused dependencies mean a smaller attack surface and less reading for newcomers.
Why speed without tests turns into an illusion
A team without tests looks fast only at the front. The first weeks move quickly because no test code gets written. Features keep shipping. Then the pace bends. Every edit drags manual checking behind it. Someone clicks through by hand, reads output with eyes, asks a colleague for a quick look. Human checking speed stays flat while the area to check keeps growing.
Automated tests hand that checking to machines. Machines do not tire and repeat the same check the same way. That is why refactoring feels brave with tests around. Change the structure and green tests signal that behavior held. The signal invites bolder moves, bolder moves improve structure, better structure speeds the next change. The loop starts with one small suite.
This is where I would be careful not to over read the lesson. More tests do not always mean better. Brittle tests, tests pinned to implementation detail, suites that take an hour to run all slow a team down. I prefer tests that pin behavior rather than shape. They record inputs and outputs, state transitions, and promises to the outside. Tests that survive internal rewrites stay useful for years.
Characterization tests help a lot with old code. Before changing a legacy area, first record current behavior as tests. Fix what it does today before arguing what is right. Then reshape the structure. That one step gives a safety net when everything feels opaque. You can start changing before full understanding arrives. Understanding catches up while you change.

The new ways coding assistants get things wrong
Coding assistants are strong at increasing volume. Write a description and a skeleton appears, repeat work disappears. On the surface productivity jumps. But checking load grows with output. When unread code enters the repo faster, bad code piles up faster too.
The new failure mode looks plausible. It calls an API that does not exist with a straight face, picks unsafe defaults, and offers an implementation that reads logically yet behaves wrongly. It compiles, the demo runs, so it slips through. Reviewers pressed for speed think it probably works since it runs. An unverified guess hardens into code at that moment.
When I read assistant produced code I ask about behavior rather than origin. What inputs does this function take, what guarantees does it keep, how does failure surface. Where anything feels vague I add a test first. I confirm API existence by running rather than by reading prose. Security sensitive parts like permissions, input validation, and error paths get stricter eyes. Code that loosens a boundary for convenience later becomes a path someone can abuse.
There are also clear good uses. Removing boilerplate, drafting test skeletons, and proposing names and structure all play to the tool. The difference lies in where people spend the time saved. Time saved in writing should move into reading and verification. The assistant replaces typing but not judgment. Teams that keep a human in the judgment seat last longer.
Tools that set a limit on how bad code can get
If code has no floor, the inverse also reads true. Floors come from intent plus tooling. Intent alone rarely holds. Tired people compromise and urgent work skips steps. So we set devices that bound the range in advance. Static analysis, strong types, linters, formatters, review, and CI quality gates are those devices.
Static analysis and types catch mistakes before running. They show where a nullable value flows, whether a resource stays open, and whether a risky pattern hides nearby. Linters and formatters cut debate. When machines own indentation and import order, reviewers can focus on logic. Machines should own trivia so people can watch what counts.
Review is not a brake on speed but a guard for speed. Small changes read fast, and fast reading merges fast. Large changes read slowly and sit for days. Old branches conflict, and resolving conflicts invites mistakes. So the rule of small changes comes before review itself. Split work small and review turns easy. Easy review keeps quality gates painless.
Quality gates turn passing criteria from documents into runs. Rather than chasing an absolute coverage number, I prefer a clear bar for incoming code. New code arrives with tests, passes lint, passes type checks. Old code rises gradually. Automatic bars cut haggling over exceptions, and fewer exceptions keep the bar alive.

How to replace code little by little without a full rewrite
Facing legacy code tempts a big rewrite. Starting over sounds clean. Experience says that road runs rough. While the rewrite continues, the old system keeps changing and requirements keep changing. By the time the new system finishes, the world moved. Migration stretches, the team tires, and two versions of truth remain.
That is why incremental strangulation comes up so often. Like a strangler fig wrapping a tree, the new surrounds the old from the outside. New requests go to new modules, old paths stay still or take minimal edits. Tests guard the boundary, dependencies get cut one by one, and assured parts swap first. Small wins repeat instead of one big win.
Dependency pruning prepares the ground in that process. Drop unused libraries, gather duplicated utils, keep version policy plain. Fewer dependencies speed builds, narrow security review, and reduce reading for newcomers. It never looks flashy, but it lowers the unit price of maintenance in a sure way.
The rule of small reviewable changes points the same way. Five small changes a day beat one giant change. Small changes explain briefly, roll back easily, and reveal causes fast. Large changes do the opposite. From a team view, review speed is a design problem rather than a kindness problem. Structures that split easily make fast teams. Where splitting stays hard, effort alone cannot buy speed.

The habits of an organization shape the code
Teams writing the same language end up with different shapes. The gap comes more from habits and incentives than talent. Shipping pressure with no room for refactoring guarantees rot. Without ownership nobody guards boundaries. Where "it works" counts as praise, tests look like a luxury.
Ownership is easy to misread. It does not mean one person hoards a module. It means responsibility for quality stays clear. Each module has someone tending it, a plan exists to lift old parts, and someone holds the power to stop a change that breaks the bar for new code. With clear ownership, duplication gets fixed on sight. With blurry ownership, everyone walks past it.
Schedules work the same way. Refactoring placed outside the schedule never happens. It belongs inside. Debt repayment sits beside feature points, and a little gets paid every week. If urgent fires always push structural work to the next sprint, the tilt keeps growing. A lead should be able to say this week is for making things readable. That one line changes speed months later.
Metrics also reshape code. Judge people by lines and commits and those numbers grow. Watch failure rates on change, time to recover, review turnaround, and test run time, and behavior shifts. The line about what gets measured holds unusually well in code organizations. Good metrics fix structures without nagging people.
What I will check first the next time I read code
What I would watch next is not a grand declaration but whether a short checklist actually runs in the field. When I open a strange repo I follow a simple order. First I run the tests. I watch how fast they run and how often they break. Then I look at the size and dependency direction of the most changed files. Then I trace where exceptions flow and where secrets live. Those four views sketch the health of the place.
In teams where coding assistants are daily tools I add two lines. Were outside API calls confirmed by running, and are security defaults locked toward the safe side. Doubting plausible code is not doubting people. It is using a tool with its nature understood. Tools propose fast and people verify slowly. When that split stays clear, speed and safety move together.
The line this comment leaves behind is short and stays in mind. No floor under bad code sounds bleak, but I read it as an invitation. Without a floor we must set the ceiling ourselves. Deciding what stays allowed and what gets blocked is engineering. One lint rule, one type, one small review forms the bricks of that ceiling.
One thought to close. Code written today joins the reading list tomorrow. The reader may be a teammate or yourself months later. Good code spares that reader the question of why it looks this way. Against decline without limit, our answer is improvement with limits, repeated. Split small, check often, repay a little at a time. As the repetition builds, the codebase stops feeling like a scary place.
