Python the good, bad and ugly

Contents
<p>I have written Python for money, for fun, and at three in the morning to glue two systems together that were never meant to speak. It is the language I reach for first for almost anything that is not performance-critical or a browser, and it is also the language that has bitten me in the most avoidable ways. Loving a tool and being clear-eyed about its sharp edges are not in tension — they are the same thing. So here is my honest ledger: five things Python gets genuinely right, five that hold it back, and five that are just plain ugly and always will be.</p>
<p>First, some history, because it explains a lot about the language’s character. Guido van Rossum started Python over the Christmas break of 1989 while working on the Amoeba distributed operating system at CWI in the Netherlands. He wanted a scripting language that was easier than C but more capable than shell, and he named it after Monty Python’s Flying Circus, not the snake — a fact worth remembering when you write your first <code>import antigravity</code>. The first public release, 0.9.0, landed in February 1991. Its guiding principle, later crystallised in the Zen of Python, is “there should be one — and preferably only one — obvious way to do it.” Python has spent thirty years mostly living up to that, and occasionally, spectacularly, failing to.</p>
<h2 id="the-good">The Good</h2><div class="ad-unit ad-in-article" aria-label="Advertisement">
<span class="ad-label">Advertisement</span>
<ins class="adsbygoogle" style="display:block;text-align:center"
data-ad-client="ca-pub-3726833845844946"
data-ad-slot="3291553914"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
<p><strong>1. It is genuinely easy to read.</strong> This is not marketing. Python code, written by a competent person, reads close to pseudocode, and the enforced indentation means you cannot hide a badly-structured function inside cosmetic formatting. Six months later that readability is what lets you pick a script back up without re-deriving what it does. It is the reason Python dominates as a teaching language and as the lingua franca of data science.</p>
<p><strong>2. Batteries included, and a colossal ecosystem beyond them.</strong> The standard library alone will parse JSON, speak HTTP, run a web server, manipulate dates, and zip files without a single dependency. Beyond it, PyPI has a package for essentially anything — NumPy and pandas for data, requests for HTTP, FastAPI and Django for the web, PyTorch for machine learning. The gravitational pull of that ecosystem is Python’s real moat.</p>
<p><strong>3. It glues everything.</strong> Python’s happiest role is orchestrating other things: calling C libraries, shelling out to command-line tools, wiring an ML model to a web API. The C interface is mature, which is why the heavy numerical libraries are fast C and Fortran under a friendly Python skin. If you need raw speed for one hot loop, you drop into a compiled extension and keep the rest ergonomic — a trade I explore against other languages in <a href="/story/rust-the-good-bad-and-ugly/">Rust the good, bad and ugly</a> and <a href="/story/go-the-good-bad-and-ugly/">Go the good, bad and ugly</a>.</p>
<p><strong>4. Interactive by nature.</strong> The REPL, and Jupyter notebooks on top of it, make Python a place to think out loud. You poke at data, see the result, adjust, repeat. Compiled languages make you build to find out you were wrong; Python tells you in the next line.</p>
<p><strong>5. Cross-platform and everywhere.</strong> It ships on macOS and virtually every Linux distribution, runs fine on Windows, and turns up embedded in everything from Blender to ArcGIS. Write it once, and it very probably runs where you need it. That ubiquity has a compounding effect: because Python is already on the machine, it becomes the default answer for the ten-line automation nobody wanted to write a whole program for, and those ten-line scripts quietly accrete into the connective tissue of an entire infrastructure. Shell scripts are still the tool for the very smallest glue jobs — I have my own opinions on that in <a href="/story/bash-the-good-bad-and-ugly/">Bash the good, bad and ugly</a> — but the moment a script needs a real data structure or an HTTP call, Python is where I go, and it is almost always already installed.</p>
<h2 id="the-bad">The Bad</h2>
<p><strong>1. It is slow, and you will notice.</strong> CPython is an interpreter, and pure-Python numeric or tight-loop code is one to two orders of magnitude slower than C. For a huge amount of work this simply does not matter — the bottleneck is the network or the database, not the interpreter. But when it does matter, it <em>really</em> does, and you end up rewriting the hot path in C, Cython, or Rust.</p>
<p><strong>2. The GIL.</strong> The Global Interpreter Lock lets only one thread execute Python bytecode at a time, so threads do not buy you CPU parallelism for compute-bound work — you reach for <code>multiprocessing</code> instead, with all its overhead. This is finally changing: PEP 703 landed an experimental free-threaded build in Python 3.13, and PEP 779 promoted that build to officially <em>supported</em> (though not yet default) in Python 3.14, released in October 2025. The performance gap of a free-threaded interpreter has fallen from around 40% in 3.13 to under 10%. The GIL’s days are numbered, but for now it still shapes how you write concurrent Python.</p>
<p><strong>3. Packaging is a perennial swamp.</strong> <code>pip</code>, <code>venv</code>, <code>virtualenv</code>, <code>pipenv</code>, <code>poetry</code>, <code>conda</code>, and now <code>uv</code> — the fact that I can list seven tools is the problem. Getting a reproducible environment onto someone else’s machine has historically been the least joyful part of the Python experience. It is improving fast (<code>uv</code> is genuinely good), but “just install it” has never been as simple as it should be.</p>
<p><strong>4. Version and dependency fragility.</strong> The 2-to-3 transition was a decade-long tax the community is still recovering from psychologically. Even within Python 3, a project that pins an old library can quietly break on a new interpreter, and untyped code makes those breakages runtime surprises rather than compile-time errors.</p>
<p><strong>5. Dynamic typing cuts both ways.</strong> The freedom that makes Python fast to write also means a typo in an attribute name sails through until that branch executes in production. The fix exists — gradual type hints checked by <code>mypy</code> — but it is opt-in, and I have written a whole piece on why you should opt in: <a href="/story/python-type-hints-writing-python-like-you-mean-it/">Python type hints</a>.</p>
<h2 id="the-ugly">The Ugly</h2><div class="ad-unit ad-in-article" aria-label="Advertisement">
<span class="ad-label">Advertisement</span>
<ins class="adsbygoogle" style="display:block;text-align:center"
data-ad-client="ca-pub-3726833845844946"
data-ad-slot="3291553914"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
<p><strong>1. Mutable default arguments.</strong> The classic trap. Write <code>def f(x, items=[])</code> and <em>every</em> call that omits <code>items</code> shares the <em>same</em> list, which persists between calls. It is the first thing that makes newcomers doubt their sanity, and the fix (<code>items=None</code>, then <code>items = items or []</code>) is muscle memory for anyone who has been burned once.</p>
<p><strong>2. Silent type coercion and comparison oddities.</strong> <code>True == 1</code> is <code>True</code>. <code>isinstance(True, int)</code> is <code>True</code>. Sorting a list of mixed types throws in Python 3, which is at least an improvement, but the booleans-are-integers business still produces genuinely surprising results in code that counts things.</p>
<p><strong>3. <code>self</code> everywhere.</strong> Explicit <code>self</code> as the first parameter of every method is defensible on “explicit is better than implicit” grounds, and I even agree with it — but it is boilerplate you type ten thousand times, and forgetting it produces an error message that does not point at the real problem.</p>
<p><strong>4. Error messages that hide the point.</strong> They have improved dramatically in recent versions — 3.11 and 3.12 added genuinely helpful hints and better tracebacks. But a deep, wrapped exception chain still buries the one line you needed under a wall of framework internals, and a bare <code>KeyError: 'user'</code> a thousand lines deep tells you <em>what</em> but rarely <em>where you went wrong</em>.</p>
<p><strong>5. The two-language performance escape hatch.</strong> The very thing that makes Python fast enough — dropping into C for the hot parts — means that any serious performance investigation eventually leaves Python behind. You end up debugging a C extension’s segfault, and no amount of Python readability helps you there.</p>
<h2 id="a-worked-example-of-the-ugly">A worked example of the ugly</h2>
<p>Because the mutable-default trap is the one that turns believers into doubters, it is worth seeing on the page. Here is code that looks obviously correct and is not:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">append_to</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">target</span><span class="o">=</span><span class="p">[]):</span>
</span></span><span class="line"><span class="cl"> <span class="n">target</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"> <span class="k">return</span> <span class="n">target</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">append_to</span><span class="p">(</span><span class="mi">1</span><span class="p">))</span> <span class="c1"># [1]</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">append_to</span><span class="p">(</span><span class="mi">2</span><span class="p">))</span> <span class="c1"># [1, 2] -- wait, what?</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">append_to</span><span class="p">(</span><span class="mi">3</span><span class="p">))</span> <span class="c1"># [1, 2, 3]</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The default <code>[]</code> is evaluated <em>once</em>, when the function is defined, not each time it is called. Every call that omits <code>target</code> mutates the same shared list. The fix is a ritual every Python programmer eventually internalises:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">append_to</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">target</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl"> <span class="k">if</span> <span class="n">target</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl"> <span class="n">target</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"> <span class="n">target</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"> <span class="k">return</span> <span class="n">target</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Nothing about this is a bug in your logic; it is the language handing you a loaded footgun and trusting you to know it is loaded. That is the running theme of the “ugly” list — these are not mistakes so much as design decisions whose consequences you simply have to memorise. Once you have, they stop hurting, but the fact that you <em>must</em> is what keeps them on the ugly side of the ledger rather than the merely-bad one.</p>
<h2 id="where-i-actually-reach-for-it">Where I actually reach for it</h2>
<p>To be concrete about the “good”: when a colleague drops a messy CSV on me and asks a question by end of day, I open a notebook, <code>import pandas</code>, and I have an answer in twenty minutes. When I need to stand up an internal API to expose a model, FastAPI gets me there in an afternoon with typed request bodies and automatic docs. When two services need a translation layer, a fifty-line Python script does what a compiled program would take an afternoon of boilerplate to achieve. None of those tasks care that Python is slower than Rust, because the interpreter is never the bottleneck — the human is, and Python removes human friction better than almost anything. That is the case for the language in one paragraph, and it is why the flaws below are worth tolerating rather than fleeing.</p>
<h2 id="the-verdict">The verdict</h2>
<p>Python’s flaws are almost all the flip side of its virtues. It is slow <em>because</em> it is dynamic and high-level; packaging is messy <em>because</em> the ecosystem is enormous and old; the GIL exists <em>because</em> early Python chose simplicity over concurrency. Whether any given item on this list is “good”, “bad”, or “ugly” genuinely depends on what you are building. For scripting, data work, ML, and glue code, Python is close to unbeatable and I write it happily every week. For a low-latency service handling tens of thousands of requests a second on one box, I would look elsewhere — and I have compared the alternatives in the companion pieces to this one. Know the sharp edges, keep <code>mypy</code> on, and Python remains one of the most productive languages ever made. It just expects you to have read the manual on default arguments first.</p>
<p>Thirty-plus years after that Christmas break in Amsterdam, the language has grown far beyond anything Guido van Rossum could have planned for, and the tension in this article is the tension of that success: the choices that made Python approachable in 1991 are the same choices that make it slow and loosely-typed today. That it remains my first reach for most work, flaws fully in view, is the highest compliment I can pay a tool.</p>
Advertisement
Related Content
Advertisement




