<?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: Claire Nagase</title>
    <description>The latest articles on DEV Community by Claire Nagase (@claire_nagase_4bc3e62954f).</description>
    <link>https://dev.to/claire_nagase_4bc3e62954f</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%2F4196335%2Fbb7774b9-f523-4057-a262-8e27181f1405.png</url>
      <title>DEV Community: Claire Nagase</title>
      <link>https://dev.to/claire_nagase_4bc3e62954f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/claire_nagase_4bc3e62954f"/>
    <language>en</language>
    <item>
      <title>Your spreadsheet lookup fails because of three invisible characters</title>
      <dc:creator>Claire Nagase</dc:creator>
      <pubDate>Tue, 20 Oct 2026 15:29:44 +0000</pubDate>
      <link>https://dev.to/claire_nagase_4bc3e62954f/your-spreadsheet-lookup-fails-because-of-three-invisible-characters-3gf8</link>
      <guid>https://dev.to/claire_nagase_4bc3e62954f/your-spreadsheet-lookup-fails-because-of-three-invisible-characters-3gf8</guid>
      <description>&lt;p&gt;A reader sent me a spreadsheet last week with a lookup that returned &lt;code&gt;#N/A&lt;/code&gt; for every row. The values were obviously there. You could see &lt;code&gt;Widget&lt;/code&gt; in column A and &lt;code&gt;Widget&lt;/code&gt; in the lookup table. Identical, as far as the screen was concerned.&lt;/p&gt;

&lt;p&gt;They were not identical. One of them was &lt;code&gt;Widget&lt;/code&gt; followed by a non-breaking space, a character that renders as nothing and survives every copy-paste, every save, and every "just retype it" attempt.&lt;/p&gt;

&lt;p&gt;This is the most common spreadsheet bug I know of, and it is invisible by construction. Here are the four characters responsible, how to find them in ten seconds, and a normaliser you can paste into your own workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the values look identical
&lt;/h2&gt;

&lt;p&gt;A spreadsheet shows glyphs. Your comparison runs on code points. Between those two things sits a whole category of characters that occupy no visible width:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;U+FEFF, the byte-order mark.&lt;/strong&gt; Excel writes it at the start of every "CSV UTF-8" export. If it lands in the first header cell, your column is not called &lt;code&gt;Name&lt;/code&gt;, it is called &lt;code&gt;\ufeffName&lt;/code&gt;. Every lookup against &lt;code&gt;Name&lt;/code&gt; fails, and the header looks completely normal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;U+00A0, the non-breaking space.&lt;/strong&gt; Web pages and HTML-to-Excel exports are full of them. It looks like a space, sorts like a space, and is not &lt;code&gt;" "&lt;/code&gt;. &lt;code&gt;strip()&lt;/code&gt; in most languages does not remove it by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;U+200B through U+200D, zero-width characters.&lt;/strong&gt; Copy text out of a PDF, a chat window or a CMS and you can pick these up. They are not spaces at all; they are formatting marks with no width.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate header rows.&lt;/strong&gt; Concatenate twelve monthly exports and the header row appears in the middle of your data, splitting types and breaking lookups halfway down the file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding them in ten seconds
&lt;/h2&gt;

&lt;p&gt;Stop staring at the cells. Ask the machine what is actually in them:&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;unicodedata&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show_invisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;u&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;category&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cc&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;Cf&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;Zs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;show_invisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\ufeff&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="se"&gt;\u00a0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# \ufeffName\u00a0
&lt;/span&gt;&lt;span class="nf"&gt;print&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="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\ufeff&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="nf"&gt;len&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="c1"&gt;# 5 4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;len()&lt;/code&gt; line is the one that ends arguments. If the header is five characters and you typed four, you have found your bug. In Excel the same check is &lt;code&gt;=LEN(A1)&lt;/code&gt;, which is the fastest diagnostic available and works on any machine without installing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The normaliser
&lt;/h2&gt;

&lt;p&gt;Once you know what you are dealing with, cleaning is a few lines. This handles the BOM, the non-breaking space, zero-width characters, and stray padding:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\ufeff&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="c1"&gt;# byte-order mark
&lt;/span&gt;    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u00a0&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# non-breaking space
&lt;/span&gt;    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&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;[\u200b-\u200d\u2060]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# zero-width marks
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it over the header row and every cell you are about to match on. Order matters: replace the non-breaking space with a normal space &lt;em&gt;before&lt;/em&gt; stripping, otherwise &lt;code&gt;strip()&lt;/code&gt; leaves it in place and you have fixed nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the file, not the symptom
&lt;/h2&gt;

&lt;p&gt;Doing this by hand is fine for one column and hopeless for twenty thousand rows across forty files. The durable fix is to clean on the way in, once, and never think about it again:&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;export.csv&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;rb&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;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8-sig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# "utf-8-sig" eats the BOM for you
&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&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;reader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StringIO&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="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;normalise&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="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &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;_&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;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="nf"&gt;normalise&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="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;row&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;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&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="nf"&gt;strip&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;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in there are worth more than they look:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;utf-8-sig&lt;/code&gt;, not &lt;code&gt;utf-8&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;-sig&lt;/code&gt; variant strips the byte-order mark as it decodes. Without it you are back to a phantom character in column one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;any(c.strip() for c in row)&lt;/code&gt;&lt;/strong&gt; drops fully empty rows, which otherwise become rows of empty strings that break &lt;code&gt;int()&lt;/code&gt; and &lt;code&gt;float()&lt;/code&gt; later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lowercasing and underscoring headers&lt;/strong&gt; means &lt;code&gt;" Name "&lt;/code&gt; and &lt;code&gt;"name"&lt;/code&gt; and &lt;code&gt;"NAME"&lt;/code&gt; all become &lt;code&gt;name&lt;/code&gt;. Header drift is the second most common cause of broken lookups after invisible characters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the mojibake flavour — &lt;code&gt;caf\u00c3\u00a9&lt;/code&gt; instead of &lt;code&gt;café&lt;/code&gt; — the cause is different: text was decoded with the wrong encoding, then encoded back. Reversing it is a round trip: encode with the wrong codec, decode with the right one. It works, but only on text that has not been through two wrong conversions, which is why backups matter more than cleverness there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The habit that prevents all of it
&lt;/h2&gt;

&lt;p&gt;Treat every export as hostile until proven otherwise. Decode with &lt;code&gt;utf-8-sig&lt;/code&gt;, normalise every string, and assert on your header names before you do any matching. It takes four lines and it removes an entire class of bug from your life.&lt;/p&gt;

&lt;p&gt;The spreadsheets I get are rarely malicious, but they are always from somewhere else: a CRM, a payment processor, a client on Windows 7. Their encoding choices are not your fault, but they are your problem the moment the lookup fails.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The normaliser above is free regardless — go fix your lookups. If you would rather not maintain the scripts yourself, I put the two most common chores from this article into a free sampler: renaming a folder into date order, and merging a pile of spreadsheets into one table, with the setup chapter for people who have never run Python: &lt;a href="https://clairenagase.gumroad.com/l/wqqsal" rel="noopener noreferrer"&gt;clairenagase.gumroad.com/l/wqqsal&lt;/a&gt;. The full guide and the other six chores live at &lt;a href="https://clairenagase.gumroad.com/l/lnlafw" rel="noopener noreferrer"&gt;clairenagase.gumroad.com/l/lnlafw&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>productivity</category>
      <category>software</category>
      <category>python</category>
    </item>
    <item>
      <title>Seven CLI tools, zero dependencies: what the standard library actually costs</title>
      <dc:creator>Claire Nagase</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:24:23 +0000</pubDate>
      <link>https://dev.to/claire_nagase_4bc3e62954f/seven-cli-tools-zero-dependencies-what-the-standard-library-actually-costs-mbo</link>
      <guid>https://dev.to/claire_nagase_4bc3e62954f/seven-cli-tools-zero-dependencies-what-the-standard-library-actually-costs-mbo</guid>
      <description>&lt;p&gt;Every few months I install a small utility, and every few months something in its dependency tree ships a breaking change and the utility stops working. The tool was four lines of real logic wrapped in forty packages.&lt;/p&gt;

&lt;p&gt;So I set a rule for a set of command-line tools: &lt;strong&gt;import nothing outside the Python standard library&lt;/strong&gt;. Not "few dependencies" — none. If a machine has Python 3.9+, it runs. Here is what that constraint buys, and what it genuinely costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the constraint buys
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing can rot.&lt;/strong&gt; No transitive dependency can break my tool by releasing a major version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It installs where pip does not.&lt;/strong&gt; Air-gapped machines, locked-down corporate images, containers where you are not allowed to pull anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is small enough to read.&lt;/strong&gt; A few hundred lines you can audit in ten minutes beats a package you trust because it has stars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It starts instantly.&lt;/strong&gt; No import of a framework to convert a CSV file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The seven tools
&lt;/h2&gt;

&lt;p&gt;Typed CSV to JSON, JSON back to CSV, bulk renaming, duplicate detection, a link checker, Markdown to HTML, and a directory size report. Boring, recurring jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three places where the boring version is the hard version
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Renaming files is a destructive operation.&lt;/strong&gt; The naive &lt;code&gt;os.rename&lt;/code&gt; loop will overwrite a file that happens to have the target name, and you find out later. So the rename tool builds a plan first, refuses any target that already exists, prints the plan, and only then acts. Every applied batch writes an undo log, so &lt;code&gt;--undo&lt;/code&gt; replays the moves backwards. Destructive tools should default to dry-run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSV type inference is a trap.&lt;/strong&gt; &lt;code&gt;"1"&lt;/code&gt; might be a number, an ID, or a boolean. &lt;code&gt;"true"&lt;/code&gt; might be a string in a column of strings. Empty is not the same as zero, and a spreadsheet saved on Windows starts with a UTF-8 BOM that will silently corrupt your first column name. My rule: convert only unambiguous scalars, treat empty as null, and give people a flag to turn inference off entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hashing everything is wasteful.&lt;/strong&gt; Duplicate detection by SHA-256 means reading every byte of every file. Bucket by size first — two files of different sizes cannot be identical — then hash only within size groups. On a photo library this turns minutes into seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the standard library is the wrong choice
&lt;/h2&gt;

&lt;p&gt;I would not build image processing on it, or PDF parsing, or anything that needs a real HTTP client with retries and connection pooling. &lt;code&gt;urllib&lt;/code&gt; is fine for checking whether a URL returns 200; it is not fine for scraping at scale. Pretending otherwise would be ideology over engineering. The constraint is worth it for small, dependency-hostile utilities, not for everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;I would write the tests before the type-inference code. The &lt;code&gt;"1"&lt;/code&gt; vs &lt;code&gt;true&lt;/code&gt; vs &lt;code&gt;"yes"&lt;/code&gt; cases are exactly where bugs live, and the test suite caught behaviour I had already convinced myself was correct.&lt;/p&gt;




&lt;p&gt;I packaged these tools. Two of them — Markdown to a single self-contained HTML file, and the disk usage report — are free, MIT licensed, source included, so you can judge the code before deciding anything: &lt;a href="https://clairenagase.gumroad.com/l/wlsha" rel="noopener noreferrer"&gt;free lite edition&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The full set is $7 on the same store. I am the author, so treat the link as self-promotion; the technical parts above stand on their own regardless.&lt;br&gt;
Source for the two free tools (MIT) is on GitHub: &lt;a href="https://github.com/ClaireMananite/zdk" rel="noopener noreferrer"&gt;https://github.com/ClaireMananite/zdk&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>python</category>
      <category>softwaredevelopment</category>
      <category>tools</category>
    </item>
  </channel>
</rss>
