By Reckonsys Tech Labs
July 30, 2026
When teams talk about modernizing an AS/400 system, the conversation gravitates to the exciting parts: the new API, the React frontend, retiring the green screens. But after running these engagements, we will say plainly — the data migration is where the project is actually won or lost.
The reason is simple. You can re-run a rewrite of a program if the logic is wrong. You cannot un-corrupt a customer's account balance after go-live. Financial ledgers, inventory positions, insurance policies, and order histories have been accumulating on DB2 for i for decades, and the business trusts them absolutely. A migration that introduces a fraction-of-a-cent rounding error across ten million rows does not just fail a test — it destroys confidence in the entire modernization programme.
So our first principle is uncompromising: every row, every field, every decimal place must reconcile between the old system and the new one, provably, before anyone cuts over. This is how we get there.
What DB2 for i Actually Stores — and What a Naïve Migration Misses
DB2 for i is a mature relational database — which is good news. You are not reverse-engineering a proprietary flat-file format. But it carries decades of conventions that a naive SELECT * INTO PostgreSQL will silently mangle. Four of them matter most in every migration we run.
| Convention | DB2 for i Behaviour | What Breaks If You Ignore It |
|---|---|---|
| Packed and zoned decimal | P (packed) or S (zoned) fields store exact decimal values with fixed scale | Mapping to a floating-point type is the single most common and most damaging mistake. Money and quantities lose precision silently. A ten-million-row ledger migrated into FLOAT will not reconcile — ever. |
| EBCDIC + fixed-width characters | Character data in EBCDIC, fields space-padded to their full declared length | 'ACME' in a 30-character field is 'ACME' followed by 26 spaces. Both the encoding conversion and the padding trim must be handled explicitly on extraction — or your VARCHAR fields carry trailing garbage. |
| Dates encoded as numbers | Dates stored as packed integers in YYYYMMDD, MMDDYY, or CYYMMDD format, with 0 or 999999 as sentinels for 'no date' | Load these as integers into a DATE column and the load crashes. Convert them without identifying sentinel values and you get arbitrary dates in the 1900s or 9999 appearing in real data. |
| Logical files, keys, referential rules | Physical files (PF) are tables; logical files (LF) are views and access paths. Referential integrity may be database-enforced, application-enforced, or not enforced at all. | Assuming referential integrity exists where it does not will cause FK constraint failures on load. Assuming it does not exist where it does will produce orphaned records in the target. It must be audited, not assumed. |
The Six-Stage Migration Methodology
We treat data migration as its own disciplined programme with six stages, each with an explicit exit criterion. You do not proceed to the next stage until the current one reconciles. This structure is what makes the cutover boring by design — which is exactly what you want at 2 AM on a Saturday.
Stage 1 — Profile Before You Touch Anything
Before designing a target schema, we profile the real data. The DDS definition tells you what the field was designed to contain. Twenty-five years of production tells you what is actually in it. The gap between those two is where migrations fail.
We query system catalogs to inventory structure, then profile the actual contents for the surprises that break loads:
-- Every column, type, length, and scale in a schema
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE,
LENGTH, NUMERIC_SCALE, IS_NULLABLE, COLUMN_TEXT
FROM QSYS2.SYSCOLUMNS
WHERE TABLE_SCHEMA = 'APPLIB'
ORDER BY TABLE_NAME, ORDINAL_POSITION;
-- Profile a suspect 'date' column: what values really live there?
SELECT MIN(LSTORDDT) AS min_val, MAX(LSTORDDT) AS max_val,
SUM(CASE WHEN LSTORDDT = 0 THEN 1 ELSE 0 END) AS zero_dates,
COUNT(*) AS total
FROM APPLIB.CUSTMAST;
The output is a data profile report: real value ranges, null and sentinel counts, orphaned foreign keys, duplicate 'unique' keys, and encoding anomalies. This report drives every transformation rule that follows — and it routinely surfaces data issues the client did not know they had.
✓ Exit Criterion: Every column has a documented source type, real value range, and target mapping before Stage 2 begins.
Stage 2 — Translate the Schema Deliberately
DDS-to-DDL is not a mechanical one-liner. It is a set of decisions, each of which is recorded in a column-by-column mapping document. Here is the type-mapping policy we apply as a baseline:
| DB2 for i (DDS) | PostgreSQL | Decision Note |
|---|---|---|
| P packed decimal (n, d) | NUMERIC(n, d) | Exact scale preserved. Never FLOAT or DOUBLE — this is non-negotiable. |
| S zoned decimal (n, d) | NUMERIC(n, d) | Same rule as packed. Exact scale always. |
| A char (n) | VARCHAR(n) or TEXT | Trim trailing padding on extraction. Normalise empty-after-trim to NULL. |
| Numeric YYYYMMDD 'date' | DATE | Convert with explicit function. Map sentinels (0, 99999999) to NULL — never to a real date. |
| Numeric HHMMSS 'time' | TIME | Same treatment as date fields. |
| Logical file (LF) | INDEX or VIEW | Access path → index. Join view → SQL view. Document which LFs exist and which have equivalents. |
A representative example: a CUSTMAST physical file with customer ID, name, credit limit, balance, status, and a packed-numeric date field maps to a PostgreSQL table with INTEGER, VARCHAR(30), NUMERIC(11,2) for both financial columns, CHAR(1) with a CHECK constraint enforcing the profiled domain, and a nullable DATE column where the packed zero becomes NULL.
We widen NUMERIC(9,2) to NUMERIC(11,2) deliberately — DB2's 9P2 allows seven integer digits plus two fractional, so the widened target preserves headroom and makes the decision explicit in the DDL comment. Every choice like this is recorded in the mapping spec, not left as an accident.
✓ Exit Criterion: A reviewed DDL script and a signed-off column-by-column mapping document before Stage 3 begins.
Stage 3 — Extract Exactly, Transform Correctly
We connect to DB2 for i and extract in a way that preserves precision end to end. The two most important transformation functions handle the two most dangerous conventions — packed-numeric dates and fixed-width character padding:
def to_date_yyyymmdd(n):
"""Convert packed-numeric YYYYMMDD to a real date.
Sentinels (0, 99999999) become NULL. Bad values are
logged and raised — never silently dropped."""
if n is None:
return None
i = int(n)
if i in (0, 99999999):
return None
s = f"{i:08d}"
return date(int(s[0:4]), int(s[4:6]), int(s[6:8]))
def clean_char(value):
"""Trim fixed-width padding; normalise empty to None."""
if value is None:
return None
trimmed = value.rstrip()
return trimmed or None
The recurring theme across every extraction function: numeric values pass through Python's Decimal type — never a float. Dates and encodings are converted explicitly. Anything unparseable is raised and logged, never quietly dropped. Silent data loss is worse than a failed load, because it is invisible until reconciliation — or worse, until a customer complains.
Rows are extracted in batches of 5,000 using a generator, so memory stays flat regardless of table size. A 200-million-row file loads with the same memory profile as a 200,000-row file.
✓ Exit Criterion: Every row extracts and transforms without unlogged exceptions. All rejects are captured in an exceptions table for review.
Stage 4 — Load Fast and Safely
For anything beyond a few thousand rows, row-by-row INSERT is orders of magnitude too slow. We stage the data and use PostgreSQL's COPY command — which loads data directly from a buffer rather than parsing individual SQL statements — and defer constraint validation until after the bulk load completes.
The staging pattern is non-negotiable in our process:
This makes the load idempotent and re-runnable. You can drop staging, fix a transformation bug, and re-load without disturbing anything downstream. On a migration with twenty tables and six iterations of refinement, that property is not a nicety — it is the thing that keeps the project on schedule.
✓ Exit Criterion: All rows land in staging. The load is repeatable and produces identical results across runs.
Stage 5 — Reconcile, and Prove It
This is the stage that earns the trust. We never declare a table migrated because the load succeeded. We prove equivalence with three independent checks, applied in order, each catching a class of error the previous one cannot.
| Check | What It Catches | Why It Is Not Enough Alone |
|---|---|---|
| Row counts | Missing or duplicated records | Counts can match while every single row has a corrupted field. A million rows with a rounding error in the balance column will still report matching counts. |
| Control totals | Numeric corruption — rounding errors, float precision loss, shifted decimal places | Aggregates can cancel out per-row errors. A positive error on one row and an equal negative error on another produces a matching sum but two corrupt records. |
| Row-level checksums | Per-record differences that aggregates hide — swapped fields, truncated names, shifted dates, encoding artefacts | This is the definitive check. Every row's business content is hashed on both sides and compared. The diff set must be empty. There are no acceptable mismatches. |
The checksum approach works by computing a deterministic hash of the business columns on both the DB2 source and the PostgreSQL target, then comparing key by key:
-- PostgreSQL: hash each row's business content
SELECT customer_id,
md5(concat_ws('|', name, credit_limit, balance,
status, to_char(last_order_date, 'YYYYMMDD')))
AS row_hash
FROM customer_master
ORDER BY customer_id;
-- Python: compare source and target hash dictionaries
def reconcile(src_hashes, tgt_hashes):
diffs = []
for key in src_hashes.keys() | tgt_hashes.keys():
s, t = src_hashes.get(key), tgt_hashes.get(key)
if s != t:
diffs.append({"key": key, "match": False})
return diffs  # must be empty before table is signed off
The reconciliation report — row counts equal, control totals equal to the cent, zero checksum mismatches — is a formal deliverable. A table is not done until that report is clean and the client's data owner has signed it off. Not the project manager. The data owner.
✓ Exit Criterion: Counts match, control totals match exactly, and the checksum diff set is empty. Client data owner sign-off obtained.
Stage 6 — Keep in Sync, Then Cut Over With a Rollback Path
A production system cannot freeze for the days a full migration takes. After the initial bulk load and reconciliation, we run change data capture (CDC) to keep PostgreSQL in step with the still-live AS/400 until the cutover window. DB2 for i's journals are the reliable source of changes — we read committed journal entries for the migrated files and apply the corresponding inserts, updates, and deletes to PostgreSQL on a short interval.
def apply_changes(since_seq, pg):
changes = read_journal_entries('APPLIB/QSQJRN', since_seq)
cur = pg.cursor()
for c in changes:
if c.operation in ('PT', 'UP'):Â Â # put / update
cur.execute(UPSERT_SQL, transform_record(c.after_image))
elif c.operation == 'DL':Â Â Â Â Â Â Â Â Â # delete
cur.execute('DELETE FROM customer_master
WHERE customer_id = %s', (int(c.key),))
pg.commit()
return changes[-1].sequence if changes else since_seq
Cutover then becomes a brief, well-rehearsed window: quiesce writes on the AS/400, apply the final journal delta, run reconciliation one last time, flip the application to PostgreSQL, and keep the AS/400 online in read-only mode as the rollback path.
Because every prior stage reconciled, the cutover is boring by design. Which is exactly what you want at 2 AM on a Saturday.
✓ Exit Criterion: Final delta applied, reconciliation clean, application pointed at PostgreSQL, AS/400 kept online as read-only rollback path until new platform has carried real production traffic.
How Claude Code Accelerates the Data Work
The six-stage methodology above is the same disciplined approach careful teams have used for years. What has changed is how fast we can execute it. Our data engineers use Claude Code to compress the mechanical parts of each stage:
Our engineers review and own every line. They apply the architectural judgment and edge-case handling that the profiling reveals — the sentinel values nobody documented, the referential integrity that exists in application code but not in the database, the column that looks like a date but is actually a period-end flag.
The pattern is AI-accelerated, human-governed. The tedious 80% is automated. Correctness stays a human responsibility.
What We Have Learned the Hard Way
Every principle below comes from a real project scar. Not a theoretical risk — a real event on a real client engagement.
| Principle | Why We Hold to It | Principle |
|---|---|---|
| Decimal, always | Money and quantities never touch a floating-point type — not in the extraction, not in the transform, not in staging, not in the target schema. This is non-negotiable and has no exceptions. | Decimal, always |
| Profile the data, not the schema | The DDS says what the field was designed to hold. Twenty-five years of production says what is really in it. The gap between those two is where loads fail and reconciliations break. | Profile the data, not the schema |
| Stage everything | Never load straight into production tables. Staging makes the migration idempotent, re-runnable, and safe to validate before a single production row is touched. | Stage everything |
| Reconcile before you celebrate | A successful load is not a successful migration. Counts, control totals, and checksums — in that order — or it did not happen. A table is not done until the data owner has signed the reconciliation report. | Reconcile before you celebrate |
| Keep the old system as your rollback | The AS/400 stays online and read-only until the new platform has carried real production traffic. Reversibility is what makes a cutover safe enough to execute confidently. | Keep the old system as your rollback |
Working with Reckonsys on AS/400 Data Migration
Data migration is a core part of the AS/400 modernization engagements we run at Reckonsys — not a phase we hand off or treat as a handoff from the application rewrite team. It is a parallel, disciplined programme with its own engineering practice, its own tooling, and its own sign-off process.
We start every migration engagement with a discovery-and-profiling assessment. That assessment produces three deliverables before a single row moves:
From there we can own delivery end to end, extend your team with data engineers who know both DB2 for i and PostgreSQL deeply, or advise your team through the reconciliation and cutover process.
The AS/400's data is the most valuable thing your business owns. Moving it should feel careful, provable, and — by the time you cut over — completely undramatic.
Let's collaborate to turn your business challenges into AI-powered success stories.
Get Started