<?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: Ibrahim Oulkhir</title>
    <description>The latest articles on DEV Community by Ibrahim Oulkhir (@mrblizo).</description>
    <link>https://dev.to/mrblizo</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%2F4243508%2F3fd860c0-d3a1-473d-9f7c-a38b1664d9b3.png</url>
      <title>DEV Community: Ibrahim Oulkhir</title>
      <link>https://dev.to/mrblizo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mrblizo"/>
    <language>en</language>
    <item>
      <title>Moving a desktop app from PostgreSQL to SQLite without losing a cent</title>
      <dc:creator>Ibrahim Oulkhir</dc:creator>
      <pubDate>Fri, 13 Nov 2026 19:25:40 +0000</pubDate>
      <link>https://dev.to/mrblizo/moving-a-desktop-app-from-postgresql-to-sqlite-without-losing-a-cent-58ll</link>
      <guid>https://dev.to/mrblizo/moving-a-desktop-app-from-postgresql-to-sqlite-without-losing-a-cent-58ll</guid>
      <description>&lt;h2&gt;
  
  
  Why PostgreSQL had to go
&lt;/h2&gt;

&lt;p&gt;Personal Finance Intelligence (PFI) is a local-first budgeting app for Windows: a WinUI 3 shell, a FastAPI backend and Next.js screens, with no account and no cloud. Up to version 0.3 it ran its own private PostgreSQL server inside the user's data folder.&lt;/p&gt;

&lt;p&gt;That worked, but it was the wrong tool for a single-user desktop app. The Microsoft Store build tripped over PostgreSQL's files, PostgreSQL could not run from the Store's read-only install folder without copying its binaries elsewhere first, and the app carried a whole database server for one user. Version 0.4 moved everyone to a single SQLite file; 0.5 removed PostgreSQL, which made the app about a third smaller to download and 155 MB smaller on disk.&lt;/p&gt;

&lt;p&gt;The hard part was not SQLite. It was moving real people's money records from one database to another on their own PCs, with nobody there to fix it if it went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules
&lt;/h2&gt;

&lt;p&gt;Three rules decided every design choice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never lose a cent.&lt;/strong&gt; Every amount must arrive exactly, and the app must prove it, not assume it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never touch the original until the copy is proven.&lt;/strong&gt; If anything fails, the user keeps running on their old data as if nothing happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never delete on the user's behalf.&lt;/strong&gt; The old database is kept after the move and only removed when the user asks, one release later.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Exact money in SQLite
&lt;/h2&gt;

&lt;p&gt;PostgreSQL has a real &lt;code&gt;NUMERIC&lt;/code&gt; type; SQLite does not, and storing amounts as floats would make &lt;code&gt;0.10 + 0.20&lt;/code&gt; drift. PFI stores every amount as an integer scaled by 10², so CHF 12.34 is &lt;code&gt;1234&lt;/code&gt;. A small SQLAlchemy &lt;code&gt;TypeDecorator&lt;/code&gt; does the conversion in both directions:&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;class&lt;/span&gt; &lt;span class="nc"&gt;ExactDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TypeDecorator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"""A decimal stored as an integer scaled by 10 ** scale."""&lt;/span&gt;
    &lt;span class="n"&gt;impl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInteger&lt;/span&gt;
    &lt;span class="n"&gt;cache_ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nb"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quantum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decimal&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scaleb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_bind_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&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;dialect&lt;/span&gt;&lt;span class="p"&gt;)&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;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;str&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="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_integral_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ROUND_HALF_EVEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_result_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&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;dialect&lt;/span&gt;&lt;span class="p"&gt;)&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;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&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="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quantum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ExactDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the database only ever sees integers, &lt;code&gt;SUM()&lt;/code&gt; and comparisons are exact, and they run inside SQLite rather than in Python. Rounding is half-to-even, as a &lt;code&gt;NUMERIC(p, 2)&lt;/code&gt; column would do. A test adds ten amounts of 0.10 and one of −1,234,567.89 and checks the total is exactly −1,234,566.89.&lt;/p&gt;

&lt;h2&gt;
  
  
  The copy: prove it, then switch
&lt;/h2&gt;

&lt;p&gt;On the first start of 0.4, if the data folder still held a PostgreSQL cluster, the app moved it before opening anything else:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the old PostgreSQL one last time and bring its schema up to the newest 0.3 version.&lt;/li&gt;
&lt;li&gt;Create the new database as &lt;code&gt;pfi.sqlite.part&lt;/code&gt;, never as the real file name, and run the SQLite schema migrations on it.&lt;/li&gt;
&lt;li&gt;Copy every table in foreign-key order, 2,000 rows at a time, with foreign keys switched off during the copy. Timestamps with a time zone are converted to UTC, because SQLite keeps no time zones.&lt;/li&gt;
&lt;li&gt;Check the copy: the same row count in every table, the same &lt;code&gt;SUM(amount)&lt;/code&gt; over all transactions (exact, thanks to the integer cents), and &lt;code&gt;PRAGMA foreign_key_check&lt;/code&gt; returning nothing.&lt;/li&gt;
&lt;li&gt;Only then rename &lt;code&gt;pfi.sqlite.part&lt;/code&gt; to &lt;code&gt;pfi.sqlite&lt;/code&gt; with &lt;code&gt;os.replace&lt;/code&gt;, which is atomic on the same disk.&lt;/li&gt;
&lt;li&gt;Rename the old &lt;code&gt;pgdata&lt;/code&gt; folder to &lt;code&gt;pgdata-0.3&lt;/code&gt;. Nothing is deleted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any check fails, the &lt;code&gt;.part&lt;/code&gt; file is removed and the user sees a plain message: nothing was changed, the old data is still in use, and the log file to send. The core of the check:&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;src_sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sconn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;sum&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;tables&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"transactions"&lt;/span&gt;&lt;span class="p"&gt;]&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dest_sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dconn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&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;if&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src_sum&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;dest_sum&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"0.00"&lt;/span&gt;&lt;span class="p"&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;raise&lt;/span&gt; &lt;span class="nc"&gt;LegacyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"The transaction total differs after copying ..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;broken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dconn&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="s2"&gt;"PRAGMA foreign_key_check"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;(&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;broken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;LegacyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{len(broken)} broken references after copying ..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While 0.4 was current, the build tested this path on every build: its smoke test created a 0.3-style PostgreSQL cluster with sample data, started the new app on it and compared the totals.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if it stops halfway?
&lt;/h2&gt;

&lt;p&gt;A reader asked the right question: what happens if the PC loses power in the middle of the copy?&lt;/p&gt;

&lt;p&gt;Nothing breaks, by construction. Until step 5 the real &lt;code&gt;pfi.sqlite&lt;/code&gt; does not exist and &lt;code&gt;pgdata&lt;/code&gt; has not been renamed, so on the next start the app sees exactly what it saw the first time: an old cluster and no SQLite file. It deletes any leftover &lt;code&gt;pfi.sqlite.part&lt;/code&gt; (and its &lt;code&gt;-wal&lt;/code&gt; and &lt;code&gt;-shm&lt;/code&gt; files) and starts the copy again from scratch. There is no “resume” to get wrong, because the copy is all or nothing.&lt;/p&gt;

&lt;p&gt;Old backups needed the same care. Version 0.3 wrote backups with &lt;code&gt;pg_dump&lt;/code&gt;, and a user might restore one months later. Version 0.4 could still read them: it restored the &lt;code&gt;.dump&lt;/code&gt; into a throw-away PostgreSQL cluster in a temporary folder, then ran the same checked copy into SQLite. Since 0.5, which no longer ships PostgreSQL, the app explains that a &lt;code&gt;.dump&lt;/code&gt; file needs 0.4.0 and links to that installer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retiring PostgreSQL, one release later
&lt;/h2&gt;

&lt;p&gt;Version 0.4 still shipped PostgreSQL, but only to perform the move once. Version 0.5 removed it completely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Settings shows an “Old data from PFI 0.3” row with the size of what is left (&lt;code&gt;pgdata-0.3&lt;/code&gt;, the copied binaries, old server logs) and deletes it only when the user clicks. Old &lt;code&gt;.dump&lt;/code&gt; backups are deleted only if the user ticks a separate box.&lt;/li&gt;
&lt;li&gt;If someone jumps straight from 0.3 to 0.5, the app does not guess. It refuses to open the old data and shows a page explaining that 0.4.0 must run once first, with a link to that installer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: one SQLite file instead of a database server, no background service, backups that are a single zip of that file, and an app about a third smaller to download. My own data went from 0.3 to 0.4 to 0.5 this way, and I checked each step in the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do again
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write to a temporary name, verify, then rename.&lt;/strong&gt; It turns “what if it crashes halfway” into a non-question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check totals, not just row counts.&lt;/strong&gt; A row count can match while an amount was rounded; an exact sum cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the old data for a release.&lt;/strong&gt; Disk space is cheap; a user's ten years of transactions are not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the error messages say what to do.&lt;/strong&gt; Every failure above tells the user that nothing changed and what to try next.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same idea now runs the other way: version 0.5.1 adds a “Test this backup” button that opens a backup in a temporary folder and compares balances, counts and a random sample of transactions with the live data, without restoring anything.&lt;/p&gt;

&lt;p&gt;PFI is free and open source (MIT). I built it with Claude as a coding assistant; the design decisions, testing on my own data and the releases are mine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/MrBlizo/personal-finance-intelligence" rel="noopener noreferrer"&gt;github.com/MrBlizo/personal-finance-intelligence&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Website: &lt;a href="https://mrblizo.github.io/personal-finance-intelligence/" rel="noopener noreferrer"&gt;mrblizo.github.io/personal-finance-intelligence&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Store: &lt;a href="https://apps.microsoft.com/detail/9MZKW4H9F0J5" rel="noopener noreferrer"&gt;apps.microsoft.com/detail/9MZKW4H9F0J5&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>postgres</category>
      <category>windows</category>
    </item>
  </channel>
</rss>
