<?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: Paulo Antunes</title>
    <description>The latest articles on DEV Community by Paulo Antunes (@pauloantunes).</description>
    <link>https://dev.to/pauloantunes</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%2F4128619%2F6066c9e4-e6fe-4a83-b367-b51aef7e3c5b.jpg</url>
      <title>DEV Community: Paulo Antunes</title>
      <link>https://dev.to/pauloantunes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pauloantunes"/>
    <language>en</language>
    <item>
      <title>The rollback that only rolled back half of it</title>
      <dc:creator>Paulo Antunes</dc:creator>
      <pubDate>Fri, 18 Sep 2026 19:14:29 +0000</pubDate>
      <link>https://dev.to/pauloantunes/the-rollback-that-only-rolled-back-half-of-it-51dg</link>
      <guid>https://dev.to/pauloantunes/the-rollback-that-only-rolled-back-half-of-it-51dg</guid>
      <description>&lt;p&gt;I was testing a new screen against the production ERP database: a box comes back from the customer, and the screen has to release the material inside it so it can be scanned into a new order. Two writes, one transaction, and a &lt;code&gt;ROLLBACK&lt;/code&gt; at the end so the rehearsal wouldn't leave a trace.&lt;/p&gt;

&lt;p&gt;The rollback ran. No error. And half of it stayed.&lt;/p&gt;

&lt;p&gt;The item table was back to its old state. The volume table was not — the write had persisted, in a transaction I had just explicitly undone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database is not one database
&lt;/h2&gt;

&lt;p&gt;The schema is old. It has grown for years around a Delphi ERP that runs a real factory, and over that time it has been touched by people with different defaults. Some tables were created in the MyISAM era. The newer ones are InnoDB.&lt;/p&gt;

&lt;p&gt;MyISAM has no transactions. An &lt;code&gt;UPDATE&lt;/code&gt; against a MyISAM table is applied the moment it runs. Wrapping it in &lt;code&gt;BEGIN&lt;/code&gt; doesn't do anything, &lt;code&gt;COMMIT&lt;/code&gt; doesn't do anything, and &lt;code&gt;ROLLBACK&lt;/code&gt; doesn't do anything either. It does not fail, and it does not warn you. The transaction simply doesn't include that table.&lt;/p&gt;

&lt;p&gt;So in a routine that writes to both families, there is no "all or nothing". There are two independent writes, one of which is a point of no return, and the illusion that you have a transaction around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that decides is not in the document I trust
&lt;/h2&gt;

&lt;p&gt;This is the part that still bothers me.&lt;/p&gt;

&lt;p&gt;I keep an exported snapshot of the schema in the repository — JSON, 342 tables, 6,477 columns, every index. I use it constantly, and it's the file my CLAUDE.md tells the AI agent to consult before it writes any SQL, precisely so nobody invents a column name from memory.&lt;/p&gt;

&lt;p&gt;It has tables. It has columns. It has indexes. It does &lt;strong&gt;not&lt;/strong&gt; have the storage engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ic&lt;/span&gt; engine docs/db/schema.json
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The artifact I treat as the source of truth about the database is silent about the one property that determines whether my transaction is real. Nothing in the application layer fills that gap: SQLAlchemy commits happily, the driver reports success, the row count comes back correct. The only thing that tells you is asking the server directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLES&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;TABLE_NAME&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 on any schema old enough to have outlived a MySQL version upgrade. It takes ten seconds, and the answer can be a surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four rules I write with now
&lt;/h2&gt;

&lt;p&gt;You can't get atomicity back. MyISAM tables in a running ERP are not something you convert on a Tuesday afternoon — the Delphi application writes to them, some are big, and the conversion is a separate project with its own risk. So the routine has to be correct &lt;em&gt;without&lt;/em&gt; a transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Validate everything before writing anything.&lt;/strong&gt;&lt;br&gt;
The check that decides whether the operation is allowed runs first, over all affected rows, and it's all-or-nothing on its own. A box can hold volumes from more than one order; if any of them fails the rule, nothing is released — and the screen shows exactly which row blocked it. Every guard is also repeated in the &lt;code&gt;WHERE&lt;/code&gt; of both &lt;code&gt;UPDATE&lt;/code&gt;s, so the write can't drift from the check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Order the writes: reversible first, irreversible last.&lt;/strong&gt;&lt;br&gt;
This is the whole trick. Within the pair of writes, one table can be rolled back and the other can't. So the InnoDB write goes first, commits, and only then does the MyISAM write run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1) items (InnoDB): reversible. Frees the unique code that blocks re-scanning.
&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SQL_RELEASE_ITEMS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;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="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt;

&lt;span class="c1"&gt;# 2) volume (MyISAM): NOT reversible. Runs only after the first one is committed.
&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;volumes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SQL_RELEASE_VOLUMES&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;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="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# honest no-op here, kept for symmetry
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two blocks instead of one is not a style choice. It's the shape of the guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Choose which half-finished state you want to be left with.&lt;/strong&gt;&lt;br&gt;
Ordering the writes means deciding, in advance, what the world looks like if the process dies between them. Here it's "item codes are free, volume still points at the box". That's the benign side: the thing that actually blocks reuse is a unique index on the item code, and it's already released. The leftover is a stale pointer, visible and fixable. The other order would have left the operator with a box that looks released but still refuses every scan — a support call nobody can diagnose.&lt;/p&gt;

&lt;p&gt;Pick the failure state the same way you'd pick an error message: for the person who will meet it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Make re-running it safe.&lt;/strong&gt;&lt;br&gt;
Since the operation can stop halfway, the fix has to be "do it again". Both statements are idempotent — they set values to &lt;code&gt;NULL&lt;/code&gt; under conditions that are still true — so running the screen a second time completes what the crash left behind, instead of doing damage on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  It isn't one weird table
&lt;/h2&gt;

&lt;p&gt;I thought I'd found a quirk. I'd found a property of the schema.&lt;/p&gt;

&lt;p&gt;A completely different routine — the finishing line, where an operator types a work order and the system pulls the next item from the production queue — has the same shape: the queue table is InnoDB and takes a real &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; lock, while the stock-entry table is MyISAM, so the rollback doesn't undo the stock entry. Same reasoning, same ordering decision: the stock stays right and the traceability lags, which is the safe direction of the error.&lt;/p&gt;

&lt;p&gt;And a third routine, a quality checkpoint, writes to three tables that all happen to be InnoDB — so a single &lt;code&gt;commit&lt;/code&gt; genuinely covers the set, and I wrote it as one block. That's the point: the answer is per-table, every time. "Is this atomic?" has no schema-wide answer here.&lt;/p&gt;

&lt;p&gt;The engine changes the deployment story too. Adding an index to a MyISAM table rebuilds and locks the whole table, so that migration is scheduled outside scanning hours — the same &lt;code&gt;ALTER&lt;/code&gt; on InnoDB would have been routine.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you don't have a legacy MySQL schema
&lt;/h2&gt;

&lt;p&gt;You probably still have this problem.&lt;/p&gt;

&lt;p&gt;Write to Postgres and S3 in the same request, and there's no transaction around the pair. Insert a row and publish to a queue — same thing. Call a payment API and update your own record — same thing. Every one of those is "two stores, no shared transaction", and the four rules transfer without modification: validate first, order reversible before irreversible, choose the benign half-state on purpose, make retry safe.&lt;/p&gt;

&lt;p&gt;The mixed storage engines just make it impossible to pretend otherwise. &lt;code&gt;ROLLBACK&lt;/code&gt; looks like it's protecting you right up until you check.&lt;/p&gt;

&lt;p&gt;Which is why my rehearsals now start with a query against &lt;code&gt;information_schema&lt;/code&gt;, and why the next thing I want to add to that schema export is one more column.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Python beside Delphi, not instead of it</title>
      <dc:creator>Paulo Antunes</dc:creator>
      <pubDate>Wed, 16 Sep 2026 19:59:05 +0000</pubDate>
      <link>https://dev.to/pauloantunes/python-beside-delphi-not-instead-of-it-ld4</link>
      <guid>https://dev.to/pauloantunes/python-beside-delphi-not-instead-of-it-ld4</guid>
      <description>&lt;p&gt;I maintain an ERP that runs a footwear factory. It is a Delphi VCL desktop application: 488 units, roughly 160,000 lines of Object Pascal, sitting on a MySQL database of 342 tables. It handles production planning, warehouse management, shipping, inventory and sales. It has been in production for years, and the factory does not stop so that I can refactor.&lt;/p&gt;

&lt;p&gt;I am the only engineer on it. There are other developers in the company, but nobody else touches this system.&lt;/p&gt;

&lt;p&gt;Every conversation about a codebase like this eventually arrives at the same place: &lt;em&gt;when are you rewriting it?&lt;/em&gt; The honest answer is never, or at least not as a project with a start date. A rewrite means running two systems in parallel for years while the business keeps changing under both. That is not a technical problem I can solve alone.&lt;/p&gt;

&lt;p&gt;So Python did not arrive to replace the Delphi. It arrived &lt;strong&gt;beside&lt;/strong&gt; it. Nothing in the Delphi build changed. Not one unit, not one dependency. The Python lives in a &lt;code&gt;tools/&lt;/code&gt; directory and never ships to a single user's machine.&lt;/p&gt;

&lt;p&gt;Here is what it actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. It makes the schema a file
&lt;/h2&gt;

&lt;p&gt;The first thing I wrote was a schema dumper. It connects to MySQL and writes the entire structure — tables, columns, types, indexes — to a single JSON file that lives in the repository.&lt;/p&gt;

&lt;p&gt;That sounds trivial. It was the highest-leverage hundred lines in the project.&lt;/p&gt;

&lt;p&gt;Before it existed, the answer to "what columns does this table have?" was a round trip to a database client, and the answer to "what did this table look like in March?" was nothing at all. Now the schema is versioned next to the code that queries it. A diff on that file shows exactly what the database did between two releases.&lt;/p&gt;

&lt;p&gt;It also turned out to be the thing that made AI coding assistants useful on this project rather than dangerous. An assistant guessing at column names in a 342-table schema invents plausible SQL that fails at runtime. Pointed at a real schema file, it stops guessing. The rule in my repo instructions is blunt: check the schema file before touching any SQL, do not recall table names from memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. It reads the Delphi source
&lt;/h2&gt;

&lt;p&gt;In this codebase, SQL lives inside Object Pascal, assembled at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight pascal"&gt;&lt;code&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'SELECT ... FROM ... WHERE ...'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spread across dozens of DAO units. There is no way to ask the Delphi compiler "show me every query that touches this table."&lt;/p&gt;

&lt;p&gt;So I wrote a Python script that parses the DAO units with regular expressions, pulls out every &lt;code&gt;SQL.Add&lt;/code&gt; and &lt;code&gt;SQL.Text&lt;/code&gt; assignment, reassembles the fragments, and produces an inventory of every query in the application. Regex parsing of a real language is normally a bad idea. For this — finding string literals in a consistent, machine-generated-ish pattern — it is completely adequate, and it took an afternoon instead of a semester.&lt;/p&gt;

&lt;p&gt;The same trick works on Delphi's form files. Keyboard shortcuts in a VCL &lt;code&gt;TActionManager&lt;/code&gt; are stored as bitmask integers. Forty-four lines of Python decode the masks back into &lt;code&gt;Ctrl+Shift+F4&lt;/code&gt; and print the whole shortcut map. That document did not exist before; nobody was going to build a Delphi tool to produce it.&lt;/p&gt;

&lt;p&gt;The useful reframe: &lt;strong&gt;the legacy source code is itself a dataset.&lt;/strong&gt; You do not need the legacy language to query it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. It pre-flights changes before they ship
&lt;/h2&gt;

&lt;p&gt;This is the pattern I would keep if I had to throw the rest away.&lt;/p&gt;

&lt;p&gt;I needed to add a validation to an export routine — the one that pushes orders to the vendor ERP the company also runs. The new check would abort the export when the data was inconsistent. The obvious risk: how many orders sitting in production right now would this new check reject?&lt;/p&gt;

&lt;p&gt;In the old workflow, I would find out after deploying, from the people whose day I had just ruined.&lt;/p&gt;

&lt;p&gt;Instead I wrote a read-only Python script that reimplements the exact queries the Delphi routine runs, executes them against production data, and prints, per order, whether the new validation would abort and why. No Delphi rebuild, no deploy, no write path at all.&lt;/p&gt;

&lt;p&gt;It is a deliberately redundant implementation, and the redundancy is the point. Two implementations of the same logic in two languages, where one of them is cheap to run against real data and physically cannot write anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. It does the jobs not worth a screen
&lt;/h2&gt;

&lt;p&gt;A spreadsheet arrives from the warehouse and needs to become a transfer order: hundreds of rows, each one a product code, a size and a quantity, fanned out across a header table and a detail table with the fiscal header cloned from a template order.&lt;/p&gt;

&lt;p&gt;Building a screen for that in Delphi is a week. It gets used four times a year.&lt;/p&gt;

&lt;p&gt;It is 260 lines of Python — &lt;code&gt;openpyxl&lt;/code&gt; in, &lt;code&gt;pymysql&lt;/code&gt; out — and it defaults to dry run. It prints everything it &lt;em&gt;would&lt;/em&gt; write and exits. Committing requires an explicit &lt;code&gt;--executar&lt;/code&gt; flag, and the writes happen inside a transaction.&lt;/p&gt;

&lt;p&gt;Dry-run-by-default is not a nicety on a script that writes to a production ERP. It is the only reason I am willing to run it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Here is the part that cost me real damage before I understood it.&lt;/p&gt;

&lt;p&gt;Delphi compiles these &lt;code&gt;.pas&lt;/code&gt; files as &lt;strong&gt;Windows-1252, no BOM&lt;/strong&gt;. Portuguese source is full of accented characters — column names like &lt;code&gt;SITUAÇÃO&lt;/code&gt;, UI strings like &lt;code&gt;Preço&lt;/code&gt;. Every modern tool in existence assumes UTF-8.&lt;/p&gt;

&lt;p&gt;The failure mode is quiet and total. A script reads a &lt;code&gt;.pas&lt;/code&gt; as UTF-8, rewrites it as UTF-8, and every byte in the 128–255 range becomes a malformed sequence. You change one line and &lt;code&gt;git diff --shortstat&lt;/code&gt; reports four hundred. Sometimes the accent is not mangled but &lt;em&gt;deleted&lt;/em&gt; — &lt;code&gt;CLASSIFICAÇÃO&lt;/code&gt; silently becomes &lt;code&gt;CLASSIFICAO&lt;/code&gt;, and now a query fails at runtime against a column that no longer matches.&lt;/p&gt;

&lt;p&gt;Two things fixed it. First, an explicit rule: any tool that writes a &lt;code&gt;.pas&lt;/code&gt; reads and writes it as CP1252, never the platform default. Second, a detection step, because the rule is not universal — a handful of newer files in this project &lt;em&gt;are&lt;/em&gt; UTF-8 with BOM, and applying the CP1252 procedure to those corrupts them just as thoroughly. So you check the file before you touch it, every time:&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&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;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\xef\xbb\xbf&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="k"&gt;else&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;cp1252&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general lesson is broader than encodings. When you bring a modern toolchain alongside an old codebase, the modern toolchain carries assumptions the old code never agreed to. Those assumptions fail silently, in bulk, and the diff is where you notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then, and only then, the web
&lt;/h2&gt;

&lt;p&gt;Once the tooling layer had been running for a while, a second thing became possible: moving individual screens to the browser.&lt;/p&gt;

&lt;p&gt;That is now a Flask application on the same MySQL database. Daily production, a machine dashboard, the bill-of-materials views, some logistics screens. Per-user and per-group permissions with page access controlled in the database. It also reaches the vendor ERP's SQL Server over ODBC, so it talks to two databases at once. It ships in Docker behind nginx.&lt;/p&gt;

&lt;p&gt;It is not a replacement. The desktop application is still open on the factory floor, hitting the same tables, and it will be for a long time. The web app is a second front end, not a migration.&lt;/p&gt;

&lt;p&gt;But I could only build it with confidence because of what came before it: a schema I could diff, an inventory of every query in the legacy system, and the habit of testing a change against production data before shipping it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself three years ago
&lt;/h2&gt;

&lt;p&gt;Do not frame it as a rewrite. Frame it as &lt;em&gt;what can I learn about this system with a language that has good libraries&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The Delphi build stayed frozen the entire time. That constraint is a feature — it means the tooling layer can never break production, so you can write it fast and carelessly and keep the care for the things that do ship.&lt;/p&gt;

&lt;p&gt;Start with the schema. Make it a file. Everything else gets easier once the shape of the data is something you can read, diff and hand to a tool.&lt;/p&gt;

</description>
      <category>python</category>
      <category>legacy</category>
      <category>devtools</category>
      <category>database</category>
    </item>
  </channel>
</rss>
