<?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: tbalcewicz</title>
    <description>The latest articles on DEV Community by tbalcewicz (@tbalcewicz).</description>
    <link>https://dev.to/tbalcewicz</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%2F4079271%2F47f91664-9ec6-4fd2-963e-f7b6677e3a57.png</url>
      <title>DEV Community: tbalcewicz</title>
      <link>https://dev.to/tbalcewicz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tbalcewicz"/>
    <language>en</language>
    <item>
      <title>The silent zero: the worst bug your monitoring system can have</title>
      <dc:creator>tbalcewicz</dc:creator>
      <pubDate>Sat, 15 Aug 2026 17:12:29 +0000</pubDate>
      <link>https://dev.to/tbalcewicz/the-silent-zero-the-worst-bug-your-monitoring-system-can-have-4e6k</link>
      <guid>https://dev.to/tbalcewicz/the-silent-zero-the-worst-bug-your-monitoring-system-can-have-4e6k</guid>
      <description>&lt;p&gt;A crash is a good outcome. Somebody gets paged, somebody fixes it, everyone moves on.&lt;/p&gt;

&lt;p&gt;The bad outcome is the one where your system finishes cleanly, reports nothing, and is wrong. No exception. No alert. A perfectly normal-looking run that says "0 new items" - when the truth is "I could not reach the source and I have no idea what is out there."&lt;/p&gt;

&lt;p&gt;I call it the silent zero, and it is the most expensive bug I know of in any system whose job is to watch something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is so easy to write
&lt;/h2&gt;

&lt;p&gt;Nobody sets out to build this. It appears through reasonable-looking defensive coding:&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;fetch_notices&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="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_notices&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="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="c1"&gt;# here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every instinct says this is the responsible version. It does not crash. It degrades gracefully. The report still goes out.&lt;/p&gt;

&lt;p&gt;But look at what the caller sees. &lt;code&gt;fetch_notices()&lt;/code&gt; returned an empty list. There are exactly two situations that produce an empty list:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the source is up, and genuinely published nothing today&lt;/li&gt;
&lt;li&gt;the source is down, unreachable, rate-limiting you, or changed its response format&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;These are opposite situations and the code cannot distinguish them.&lt;/strong&gt; One means "all clear", the other means "you are blind". They arrive as the same value.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it actually plays out
&lt;/h2&gt;

&lt;p&gt;A monitoring system is trusted in proportion to how boring it is. After a few quiet weeks, "nothing today" stops being read as information and starts being read as absence of information. That is exactly when it becomes dangerous.&lt;/p&gt;

&lt;p&gt;The failure has a shape. A portal changes its HTML, or rotates a certificate, or starts requiring a header you do not send. Your collector throws. Your &lt;code&gt;except&lt;/code&gt; swallows it. The report says zero. It says zero the next day too, and the day after.&lt;/p&gt;

&lt;p&gt;Nobody investigates a quiet inbox. Weeks later someone asks why you missed something obvious, and you discover the source has been dark since a date you have to reconstruct from git log.&lt;/p&gt;

&lt;p&gt;The cost is not the outage. It is that &lt;strong&gt;you cannot say when it started&lt;/strong&gt;, so you cannot say what you missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;A failure and an empty result must never be representable by the same value.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three things follow.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Count failures explicitly, and make them travel
&lt;/h3&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;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;notices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sources&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;notices&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URLError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JSONDecodeError&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;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;failures&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="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&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;notices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two return values. The caller cannot ignore the second without deciding to.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Put the failure in the output humans read
&lt;/h3&gt;

&lt;p&gt;Not in a log file nobody opens. In the artefact itself - the email subject, the top of the spreadsheet, the first line of the report:&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;subject&lt;/span&gt; &lt;span class="o"&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;Tender report - &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;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; new&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;failures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&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;[PARTIAL - &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;failures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; source(s) down] &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A recipient who sees &lt;code&gt;[PARTIAL - 3 sources down]&lt;/code&gt; in their inbox knows to distrust the zero. That is the whole goal.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Catch narrow, and let the unknown crash
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# hides bugs in your own code
&lt;/span&gt;&lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URLError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# only expected failures
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Broad &lt;code&gt;except&lt;/code&gt; catches your &lt;code&gt;KeyError&lt;/code&gt; from a renamed field just as happily as a network timeout. A schema change silently becomes "no results", which is the same silent zero wearing a different hat. If you did not anticipate it, you want the crash.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Isolate per source, not per run
&lt;/h3&gt;

&lt;p&gt;Wrapping the whole collection loop in one try/catch means the first failing source kills the other nine. Wrap each one. A run with seven of ten sources plus a loud warning is genuinely useful. A run that aborted at source one is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alert on silence, not just on errors
&lt;/h2&gt;

&lt;p&gt;The strongest version of this does not wait for a failure to be detected at all. It treats prolonged silence as a signal in its own right:&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;if&lt;/span&gt; &lt;span class="nf"&gt;days_since_last_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_quiet_days&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;warn&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: nothing in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; days - verify it still works&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;Sources have a normal rhythm. If one usually yields something every couple of days and has been silent for two weeks, that is worth a human glance - regardless of whether anything technically threw. Sometimes the answer is "quiet season". Sometimes it is "we have been scraping a 404 page since March".&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that costs nothing
&lt;/h2&gt;

&lt;p&gt;Open the last report your monitoring produced and ask one question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If every single source had been down, would this report look different?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is no, you do not have a monitoring system. You have a system that sends emails.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build and maintain automation and monitoring systems - mostly Python and UiPath, against public data sources that were never designed to be read by machines.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>Generating Excel reports on a server that has no Excel</title>
      <dc:creator>tbalcewicz</dc:creator>
      <pubDate>Sat, 15 Aug 2026 17:08:46 +0000</pubDate>
      <link>https://dev.to/tbalcewicz/generating-excel-reports-on-a-server-that-has-no-excel-5fdh</link>
      <guid>https://dev.to/tbalcewicz/generating-excel-reports-on-a-server-that-has-no-excel-5fdh</guid>
      <description>&lt;p&gt;Sooner or later someone asks for "just a spreadsheet, like the one we make by hand." You write it, it works on your laptop, you deploy it, and it dies - because the server has no Microsoft Office installed and never will.&lt;/p&gt;

&lt;p&gt;This is one of those problems that looks trivial and has about six sharp edges. Here is the full set, in the order you will hit them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious approach fails
&lt;/h2&gt;

&lt;p&gt;The tempting solution is COM automation - driving Excel through its object model:&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;win32com.client&lt;/span&gt;
&lt;span class="n"&gt;excel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;win32com&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Excel.Application&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# needs Excel installed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a trap even when Excel &lt;em&gt;is&lt;/em&gt; installed. COM automation of Office is explicitly unsupported by Microsoft in unattended server contexts. It leaves zombie &lt;code&gt;EXCEL.EXE&lt;/code&gt; processes, deadlocks when a modal dialog appears with nobody to click OK, and behaves differently depending on which user session it runs under.&lt;/p&gt;

&lt;p&gt;The fix is not to install Office on the server. It is to &lt;strong&gt;write the file format directly.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: CSV, and the two details everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;If the recipient just wants to open it, sort it, and filter it, CSV is enough - and it has no dependencies at all. But naive CSV arrives broken for most European users:&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;write_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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;cols&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;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-sig&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;w&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="nc"&gt;DictWriter&lt;/span&gt;&lt;span class="p"&gt;(&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;fieldnames&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delimiter&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="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;extrasaction&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="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeheader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things there are doing real work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;encoding="utf-8-sig"&lt;/code&gt;&lt;/strong&gt; writes a byte-order mark. Excel does not sniff UTF-8; without the BOM it falls back to the system codepage and every accented character turns to mojibake. This is the single most common "your export is broken" bug report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;delimiter=";"&lt;/code&gt;&lt;/strong&gt; matches the list separator on locales where the decimal separator is a comma - most of continental Europe. With a comma delimiter, the entire row lands in column A.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;newline=""&lt;/code&gt;&lt;/strong&gt; stops Python's universal newlines from doubling up &lt;code&gt;\r\n&lt;/code&gt; on Windows, which otherwise gives you a blank row between every record.&lt;/p&gt;

&lt;p&gt;If your audience is mixed-locale, you cannot satisfy both with one file. Either ship two, or move to a real workbook - which brings us to:&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: a real .xlsx with openpyxl
&lt;/h2&gt;

&lt;p&gt;When you need multiple sheets, formatting, or frozen headers, write the actual format:&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;from&lt;/span&gt; &lt;span class="n"&gt;openpyxl&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Workbook&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openpyxl.styles&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PatternFill&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Alignment&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openpyxl.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_column_letter&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;write_xlsx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cols&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;sheet_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;Report&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;wb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Workbook&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;
    &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sheet_name&lt;/span&gt;

    &lt;span class="n"&gt;header_font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Font&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bold&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="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FFFFFF&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;header_fill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PatternFill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;solid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fgColor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;12324F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;ws&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="n"&gt;cols&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;cell&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ws&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;cell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;header_font&lt;/span&gt;
        &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;header_fill&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="n"&gt;ws&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="n"&gt;row&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;c&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;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;cols&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;freeze_panes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auto_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dimensions&lt;/span&gt;

    &lt;span class="n"&gt;wb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Excel required - openpyxl writes the XML that lives inside an &lt;code&gt;.xlsx&lt;/code&gt; (it is a zip file of XML, which you can verify by renaming one to &lt;code&gt;.zip&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Column widths, because auto-fit does not exist
&lt;/h3&gt;

&lt;p&gt;There is no "autofit" in the file format - Excel computes it at render time, and openpyxl is not Excel. You approximate it:&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;autosize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&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="n"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&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="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt; &lt;span class="o"&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="nf"&gt;str&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="n"&gt;value&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;ws&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;get_column_letter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&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;c&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="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_dimensions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;get_column_letter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cap the width. One long description field will otherwise produce a column three screens wide.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gotchas that will actually bite you
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Timezone-aware datetimes raise.&lt;/strong&gt; The xlsx format has no timezone concept:&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;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="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&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;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Strings starting with &lt;code&gt;=&lt;/code&gt; become formulas.&lt;/strong&gt; If your data contains user-supplied text, this is a CSV/formula injection risk as well as a rendering bug:&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;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;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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;value&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="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;=&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="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="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"'"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Illegal control characters raise, not warn.&lt;/strong&gt; Scraped text loves to carry 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;re&lt;/span&gt;
&lt;span class="n"&gt;ILLEGAL&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;[\000-\010\013\014\016-\037]&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ILLEGAL&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="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="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;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="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;31 characters, and no &lt;code&gt;[]:*?/\&lt;/code&gt;&lt;/strong&gt; in sheet names. Exceed it and the file is silently corrupt in some readers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory.&lt;/strong&gt; openpyxl holds the whole workbook in RAM. Past ~100k rows, use write-only mode:&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;wb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Workbook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;write_only&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="n"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_sheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Report&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;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="n"&gt;ws&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="n"&gt;row&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;c&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;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;cols&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# streams, never fully in memory
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Option 3: when the config file is the real problem
&lt;/h2&gt;

&lt;p&gt;A subtler version of the same trap: reading &lt;em&gt;configuration&lt;/em&gt; from a spreadsheet. It feels convenient - non-technical colleagues can edit it - and it fails the same way, because reading &lt;code&gt;.xlsx&lt;/code&gt; through Office automation needs Office.&lt;/p&gt;

&lt;p&gt;Move configuration to JSON and keep the spreadsheet for output only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recipients"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ops@example.com"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"min_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"send_email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two notes from having got this wrong. First, be consistent about types - if a flag is the string &lt;code&gt;"true"&lt;/code&gt; in one place and boolean &lt;code&gt;true&lt;/code&gt; in another, someone will eventually write &lt;code&gt;1&lt;/code&gt; and spend an afternoon on it. Pick one and document it. Second, &lt;strong&gt;fields your code does not actually read should be deleted, not left in.&lt;/strong&gt; A config file with dead keys is worse than no config file: someone will change one, nothing will happen, and they will lose an hour before concluding the system is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule underneath all of this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never depend on a desktop application being present on a server.&lt;/strong&gt; Not Excel, not Word, not Outlook, not a PDF printer driver. Every one of those has a library that writes the format directly, and the library version will run headless, in parallel, without a user session, inside a container, and at 3am when nobody can dismiss a dialog box.&lt;/p&gt;

&lt;p&gt;The five minutes you save with &lt;code&gt;Dispatch("Excel.Application")&lt;/code&gt; cost a day the first time it hangs in production - and it hangs at the worst possible moment, because the worst possible moment is exactly when nobody is logged in to click OK.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build automation and reporting systems in Python and UiPath, mostly in places where the production environment is nothing like the developer's laptop.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Keyword search is the wrong tool for monitoring anything</title>
      <dc:creator>tbalcewicz</dc:creator>
      <pubDate>Sat, 15 Aug 2026 17:04:17 +0000</pubDate>
      <link>https://dev.to/tbalcewicz/keyword-search-is-the-wrong-tool-for-monitoring-anything-1eaa</link>
      <guid>https://dev.to/tbalcewicz/keyword-search-is-the-wrong-tool-for-monitoring-anything-1eaa</guid>
      <description>&lt;p&gt;There is a specific bug that shows up in almost every monitoring system I have seen, and it never announces itself. The system runs fine. It sends its reports on time. Nobody gets an error. And the whole time, it is quietly missing most of what it was built to find.&lt;/p&gt;

&lt;p&gt;The bug is keyword search.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it starts
&lt;/h2&gt;

&lt;p&gt;You are asked to watch some external source for things that matter to your company. Job postings, public tenders, competitor announcements, regulatory filings, whatever. The source has an API with a &lt;code&gt;q=&lt;/code&gt; parameter. So you do the obvious thing:&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&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;q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ERP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You run it. Fifty results come back. They look reasonable. You ship it.&lt;/p&gt;

&lt;p&gt;Here is what actually happened. Your query matched &lt;strong&gt;sixteen thousand&lt;/strong&gt; records. The API returned the first fifty, ordered by whatever the API felt like ordering by. The other 15,950 were silently discarded - not by you, by the pagination default you never looked at.&lt;/p&gt;

&lt;p&gt;And critically: &lt;strong&gt;there is no error.&lt;/strong&gt; No exception, no warning, no truncation flag in the response. The system reports success. You will not find out from your logs. You will find out six months later when someone asks why you missed the biggest opportunity of the quarter, and the honest answer is that you never saw it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why raising the limit does not fix it
&lt;/h2&gt;

&lt;p&gt;The first instinct is to page through everything. Sometimes you can. Usually you discover one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the API caps total pagination depth (first 1,000 results, then nothing)&lt;/li&gt;
&lt;li&gt;rate limits make a full sweep take hours&lt;/li&gt;
&lt;li&gt;the result set changes between pages, so you get duplicates and gaps&lt;/li&gt;
&lt;li&gt;16,000 records is mostly noise anyway, and now you are paying to process it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can fight this. I have. You end up with retry logic, cursor management, deduplication across pages, and a job that takes forty minutes and still cannot promise completeness.&lt;/p&gt;

&lt;p&gt;The real problem is upstream: &lt;strong&gt;you are asking the wrong question.&lt;/strong&gt; "Which records contain this string" is not the question. The question is "which records belong to the category I care about" - and on most serious data sources, somebody has already answered it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classification already exists, and it is free
&lt;/h2&gt;

&lt;p&gt;Structured sources almost always carry an official classification, assigned at publication time by the publisher, because some regulation or process required it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;EU public procurement notices carry &lt;strong&gt;CPV codes&lt;/strong&gt; - a controlled vocabulary the buyer must select from when publishing&lt;/li&gt;
&lt;li&gt;Company filings carry industry classification codes&lt;/li&gt;
&lt;li&gt;Scientific papers carry subject categories&lt;/li&gt;
&lt;li&gt;Product feeds carry taxonomy IDs&lt;/li&gt;
&lt;li&gt;Job boards carry role categories&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These fields exist specifically so that machines can filter. They are usually indexed, usually exact-match, and - this is the important part - &lt;strong&gt;the result set is bounded.&lt;/strong&gt; Filtering "category = IT services, published in the last 7 days" returns maybe 900 records. You can take all of them. There is no page two you are quietly dropping.&lt;/p&gt;

&lt;p&gt;The switch looks like this:&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;# before: unbounded, silently truncated, unknowable loss
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&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;q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ERP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# after: bounded, complete, and you can prove it
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&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;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classification-cpv IN (72000000, 48000000) AND publication-date &amp;gt;= today(-7)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;limit&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You do not have to take my word for any of this - the TED API is public and needs no key, so you can measure it yourself in about five minutes. Run a full-text query for a common term like &lt;code&gt;ERP&lt;/code&gt;, then compare the &lt;code&gt;totalNoticeCount&lt;/code&gt; the API reports against how many records it will actually hand you. That gap is the part you were never seeing. Then run the category version and watch the total drop to something you can fetch completely.&lt;/p&gt;

&lt;p&gt;Precision improves, which is nice. But the real prize is the thing that does not show up as a percentage: the stream becomes &lt;strong&gt;complete&lt;/strong&gt;. Before, you cannot say what you are missing. After, you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious objection
&lt;/h2&gt;

&lt;p&gt;"Publishers misclassify things all the time."&lt;/p&gt;

&lt;p&gt;They absolutely do. This is a real weakness and pretending otherwise would be dishonest. Someone publishing a major software procurement will occasionally file it under office supplies, and category filtering will sail straight past it.&lt;/p&gt;

&lt;p&gt;The fix is not to go back to keyword search. It is to run &lt;strong&gt;a second, independent net&lt;/strong&gt;:&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;primary&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_by_category&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CATEGORY_CODES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# complete, bounded
&lt;/span&gt;&lt;span class="n"&gt;secondary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_by_product_names&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;salesforce&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;uipath&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;databricks&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;deduplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;secondary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second pass is narrow on purpose. It searches for &lt;strong&gt;proper nouns&lt;/strong&gt; - product names, vendor names, specific systems. Proper nouns are rare enough that the result set stays small and you are not back to 16,000 hits. It exists purely to catch what the first net structurally cannot.&lt;/p&gt;

&lt;p&gt;Two nets with different failure modes beat one net with unknown holes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general principle
&lt;/h2&gt;

&lt;p&gt;The thing keyword search really costs you is not precision. It is &lt;strong&gt;knowing what you don't know.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A system that returns 50 of 16,000 matches and a system that returns 900 of 900 matches can look identical from the outside. Same shape of output, same runtime, same absence of errors. The difference only shows up in what never reached you, which is exactly the thing you cannot measure from the inside.&lt;/p&gt;

&lt;p&gt;So when you build the next monitor, before you reach for &lt;code&gt;q=&lt;/code&gt;, spend ten minutes reading the API docs for a classification field. Ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is there an official taxonomy on this data?&lt;/strong&gt; Usually yes. It is usually documented badly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is my filtered result set bounded?&lt;/strong&gt; If you cannot fetch all of it, you have not solved the problem, you have moved it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does my system do when a source returns nothing?&lt;/strong&gt; If the answer is "reports zero and exits successfully", you have a second silent failure waiting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last one deserves its own article, and it is the one that has bitten me hardest.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build and maintain automation and monitoring systems - mostly Python and RPA, mostly against public data sources that were never designed to be read by machines.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>python</category>
      <category>api</category>
    </item>
  </channel>
</rss>
