After a catastrophic flood destroyed our warehouse and forced us to rebuild from zero, I decided to do it right this time — migrate from spreadsheets to a real database, build a proper ERP, and never lose control of our data again. This is the story of that migration.
Context: The Flood That Changed Everything
In April 2024, we moved into an industrial warehouse in Alfafar, Valencia, to scale operations. Six months later, in October 2024, the DANA — a catastrophic weather event — hit Valencia. Our warehouse was in one of the hardest-hit areas. Floodwater destroyed inventory, equipment, and months of operational momentum.
What followed was two months of crisis management that had nothing to do with code. In the weeks after the flood, volunteers and military personnel came to help us recover the warehouse operations — inventory, equipment, cleanup, and logistics. Then came the insurance claims, emergency budgets with no revenue, and the mental reset of seeing everything you built underwater. It was the hardest period of my life.
But it was also a reset. When we rebuilt — and we did rebuild — I made a decision: we would do it right this time. No more duct tape. No more "good enough." We rebuilt the team from 2 to 5 employees, and I committed to building proper infrastructure: a real database, a real ERP, real documentation. The Google Sheets era was over.
The Starting Point: Why Google Sheets Had to Go
When I originally founded the company, Google Sheets was the obvious choice for managing inventory. It's collaborative, visual, everyone knows how to use it, and it's free. My warehouse team could update stock locations. I could write formulas. Everyone could see everything in real time.
The problem is that Google Sheets was never designed to be a database. And when you're processing thousands of Amazon liquidation SKUs across multiple marketplace accounts, it starts to break in ways that are hard to predict:
- API rate limits — Google Sheets API has a quota of ~60 requests per minute per user. When you have 5 automated scripts hitting the same sheet, you burn through that instantly
- Concurrency nightmares — Two scripts updating the same row at the same time. An employee editing while a script writes. Formula recalculations during data imports. Data corruption was a constant risk
- No referential integrity — A "sale" was a row in one tab. The "item" it referenced was a row in another tab. The connection? A text string (the LPN code). Typo in the LPN? Orphaned sale. No foreign key to save you
- Performance degradation — At ~8,000 rows with formulas, the sheet took 15+ seconds to load. My employees were waiting. Operations were slowing down
- No query capability — "Show me all items from truckload A2Z33838 that are still in stock and haven't been listed on eBay" requires a 200-character QUERY formula that breaks if someone inserts a column
I had to migrate. But I couldn't stop the business to do it.
The Constraint That Changed Everything
This wasn't a weekend side project migration. This was a production business with real constraints:
- 5 employees who relied on Google Sheets every day — warehouse workers scanning items, admin processing orders, logistics coordinating shipments
- Orders flowing in daily across Wallapop (our main channel), each needing to be processed within hours
- Financial data that had to be accurate to the cent — we track costs per Amazon truckload (A2Z codes) for tax and profitability reporting
- No second engineer — I was the only developer, AND I was running the business operations simultaneously
I couldn't flip a switch. I needed a migration strategy that would let me move incrementally, validate at every step, and roll back if something went wrong.
The 5-Phase Pipeline
I designed the migration as a 5-phase pipeline, where each phase could be run independently in either dry-run or execute mode:
Phase 1: EXTRACT & ANALYZE
└─ Pull all data from 3 Google Sheets tabs → CSVs
└─ Generate analysis report (row counts, data types, anomalies)
Phase 2: VALIDATE
└─ Parse every row against the target PostgreSQL schema
└─ Type conversions (dates, decimals, booleans, enums)
└─ Flag invalid rows with specific error reasons
└─ Generate validation report
Phase 3: MIGRATE
└─ Load validated data into PostgreSQL
└─ Respect foreign key ordering (items → listings → sales)
└─ Dry-run mode: simulate without writing
└─ Execute mode: actual INSERT with transaction safety
Phase 4: SYNC OPERATIONAL DATA
└─ Pull "Prep de Pedidos" (order preparation) tab
└─ Match warehouse status to database records
└─ Update pick/pack states
Phase 5: CONTINUOUS SYNC
└─ Daily: Sheet → DB delta sync
└─ Handles new rows, updates, and edge cases
└─ Runs alongside the database as source of truth transitions
The Hard Parts Nobody Talks About
1. Data Quality Is Never What You Think
Google Sheets is "flexible" — which means your data is a mess. Here's what I found when I tried to parse 8,000+ inventory rows:
- Prices stored as text with Euro symbols:
"12,50 €","12.50","12,5", and""(empty) — all meaning different things - Dates in 4 different formats depending on who entered them and which locale their browser was set to
- LPN codes (our primary identifier) with inconsistent casing, trailing spaces, and occasional duplicates
- Status fields that had evolved organically:
"vendido","VENDIDO","Vendido (devuelto)","vendido?"
I spent more time writing data cleaning and normalization code than the actual migration logic. Every edge case I fixed revealed two more.
2. Financial Reconciliation Per Truckload
We buy Amazon liquidation by the truckload. Each truckload has a code (A2Z33838, A2Z34102, etc.) and a cost. For tax purposes, I need to know: for each truckload, how much did we pay, how much did we sell, what's still in stock, and what's the margin?
In Google Sheets, this was a tab with SUMIFS formulas. In PostgreSQL, I needed to rebuild this as a view (v_resumen_a2z) and validate that the numbers matched to the cent.
Truckload | Sheet Total | DB Total | Diff | Status
A2Z33838 | €14,230.50 | €14,230.50| €0.00 | ✓ MATCH
A2Z34102 | €8,445.00 | €8,445.00 | €0.00 | ✓ MATCH
A2Z34567 | €11,200.75 | €11,198.25| €2.50 | ✗ INVESTIGATE
| | | | → 1 sale missing
| | | | → LPN XU-20240312
I generated hundreds of these reconciliation reports. Every discrepancy had to be investigated. Most were caused by timing issues (a sale recorded in the sheet but not yet extracted from Wallapop's API) or by my employees making manual corrections directly in the sheet.
3. The Parallel Operation Period
For about 3 weeks, I ran both systems simultaneously. Google Sheets remained the source of truth for my employees, while the database was being populated and validated in the background.
Every morning:
- Run the sync script to pull changes from Sheets into PostgreSQL
- Check the reconciliation report for discrepancies
- Investigate and fix any issues (usually 2-5 per day)
- Test Retool dashboards against real data
- Process the day's orders (the actual business operations)
Every evening:
- Review what employees changed in Sheets during the day
- Run the sync again
- Verify financial totals still match
This was exhausting. I was essentially running two systems, doing my job as business operator, AND debugging migration edge cases. But it was the only way to ensure confidence before the cutover.
4. The Fix Scripts
No migration is clean. I wrote 15+ specialized fix scripts for issues discovered during or after the parallel period:
fix_listing_prices_from_sheet.py— Some listing prices in the DB came from the Wallapop API (which shows the current price), but the Sheet had the original listing price. These needed reconciliationfix_cash_transaction_duplicates.py— Cash transactions that were entered twice (once by the sync script, once by the Retool UI after cutover)fix_incidents_create_returned_sales.py— Returns that existed as "incidents" in Sheets but needed corresponding "sale" records in the databaseborrar_ventas_no_cuadran_sheet.py— Sales that existed in the DB but had no match in the Sheet (ghost data from early API extraction bugs)fix_zero_price_sales.py— Sales imported with €0.00 price due to a bug in the extractor that didn't capture the price field for certain order states
Each fix script followed the same pattern: diagnose → dry-run → review → execute → verify. No blind fixes.
5. Explaining to the Team
The hardest part wasn't technical. It was explaining to 4 non-technical employees why we were changing how they worked.
"The spreadsheet works fine" is a perfectly reasonable thing for a warehouse worker to say. And from their perspective, it did work fine. They didn't see the API rate limits, the data corruption risks, or the 3 AM scripts crashing because of a formula recalculation.
I had to:
- Build Retool interfaces that were at least as easy as the spreadsheet (otherwise adoption would fail)
- Train each person individually on the new workflow
- Keep the Sheet available as read-only backup for the first 2 weeks post-cutover
- Be responsive to "this doesn't work like before" feedback (most of which was valid)
The Cutover
After 3 weeks of parallel operation, 200+ reconciliation reports, and 15 fix scripts, I documented the cutover plan:
CUTOVER CHECKLIST
─────────────────
[✓] All truckloads reconciled to €0.00 difference
[✓] All active listings matched between Sheet and DB
[✓] All pending orders present in both systems
[✓] Retool modules tested: orders, items, sales, invoicing
[✓] Employees trained on new workflow
[✓] Sheet set to read-only with banner: "USE RETOOL"
[✓] Automated extractors pointed to DB (not Sheet)
[✓] Backup snapshot of all table counts taken
[✓] Rollback procedure documented (Sheet restore)
The cutover itself was anticlimactic — which is exactly what you want. I switched the extractors to write directly to PostgreSQL, set the Google Sheets to read-only, and told my team "starting today, use Retool for everything."
What I Built (The Technical Stack)
The migration wasn't just scripts. It required building the entire target infrastructure:
- 16 modular DDL files defining the PostgreSQL schema (tables, views, materialized views, functions, triggers)
- 35+ incremental migration files for schema evolution post-cutover
- 25+ SQLModel data models in Python for the FastAPI backend
- Materialized views like
mv_portalhero_feedfor marketplace feed generation - Views like
v_resumen_a2zfor financial reporting per truckload - Trigger functions for automatic sale tracking and inventory updates
Results
6 months after the migration:
- Query times: From 15+ seconds (Sheet load) to <50ms (PostgreSQL query)
- Concurrency: 5 automated scripts + 5 employees + Retool dashboards, all hitting the DB simultaneously with zero conflicts
- Data integrity: Foreign keys, constraints, and triggers catch errors that would have silently corrupted the Sheet
- Operational scale: Expanded from Wallapop-only to Wallapop + eBay + PortalHero — something that would have been impossible with Sheets as the backbone
- Financial accuracy: Truckload reconciliation that used to take me 2 hours with SUMIFS now runs in 3 seconds as a materialized view
What I'd Do Differently
If I did this again:
- Start with the database from day one. The "we'll migrate later" tax is real and expensive. Even a simple PostgreSQL + custom ERP frontend on Retool setup from the start would have saved hundreds of hours
- Automate the reconciliation checks instead of running them manually every morning. A CI-style pipeline that emails me only when discrepancies are found
- Build the Retool UI first, then migrate. I built them in parallel, which meant employees saw half-finished interfaces during the transition period. If the UI had been polished before the cutover, adoption would have been smoother
The Takeaway
Migrating a production system isn't a technical problem. It's an operational problem that requires technical solutions. The code is maybe 30% of the work. The other 70% is:
- Understanding every edge case in your existing data
- Building confidence through parallel operation and reconciliation
- Managing the human side — training, communication, feedback
- Having the discipline to not cut corners when you're exhausted and the discrepancy count is finally down to zero
I did all of this as a solo engineer running a business with 5 employees. No team of DBAs. No dedicated QA. Just systematic engineering and a lot of coffee.
The migration pipeline is open-source: github.com/AspiranteD/sheets-to-postgres-migration