By
July 30, 2026
Why This Problem Refuses to Go Away
The AS/400 — later rebranded iSeries, System i, and today IBM i on Power — is one of the most durable platforms ever built. Banks, insurers, manufacturers, distributors, and logistics companies still run mission-critical workloads on RPG and COBOL programmes that were written in the 1990s and never stopped working. That reliability is exactly the trap: the system works, so it never gets touched, and the knowledge to maintain it retires with the people who wrote it.
The pressure to move is rarely about the hardware. It is about people, integration, and speed:
Moving to Python is attractive because it gives you a large talent pool, a mature web and data ecosystem (Django, FastAPI, SQLAlchemy, Celery, pandas), and first-class cloud support. But a naive rewrite is where most legacy modernization projects die. This guide lays out a structured, phased approach that de-risks the move — with concrete code showing how RPG and DDS constructs map to idiomatic Python, and how Reckonsys uses Claude Code to run these programmes faster and more safely than was possible even two years ago.
What You Are Actually Migrating: The AS/400 Building Blocks
Before writing a line of Python, it helps to be precise about the AS/400 components, because each one maps to a different modern concern — and treating them as a single undifferentiated 'legacy system' is how migration projects scope incorrectly from day one.
| AS/400 Concept | What It Is | Modern Equivalent |
|---|---|---|
| RPG (RPG III / IV / RPGLE) | Business logic. Older code is fixed-format (columns matter); newer code is free-format. A single program often mixes screen handling, file I/O, and calculations in one monolith. | Python service layer — pure functions/classes, no I/O, unit-testable |
| DDS Physical File (PF) | Your schema. Tables defined via Data Description Specifications — packed/zoned decimal fields, fixed-width characters, date-as-number conventions. | PostgreSQL table + SQLAlchemy or Django ORM model |
| DDS Logical File (LF) | Views and access paths (indexes) over physical files. May apply filters, reorder fields, define alternate key sequences. | PostgreSQL index, SQL view, or ORM query |
| DDS Display File (DSPF) | The green screens. Defines the 5250 terminal interface — field positions, validation, function keys. | REST API (FastAPI/DRF) + React or equivalent frontend |
| CL (Control Language) | Scripting and orchestration — job scheduling, calling programmes, file overrides, batch flows. | Python CLI / Celery task / Apache Airflow DAG |
| DB2 for i | The integrated relational database. Data already lives in a real RDBMS — which is a gift. Migration is a data-and-logic problem, not a file-format reverse-engineering problem. | PostgreSQL (or DB2 LUW during transition) |
| Packed/zoned decimal | Exact-precision numeric type used for money and quantities. Never equivalent to a floating-point type. | Python decimal.Decimal — non-negotiable |
| CHAIN / READ (record-level access) | RPG's primary data access idioms — keyed lookup and sequential cursor read. | ORM get() / queryset iteration, or set-based SQL query |
The Target Architecture
The end state we design toward is a conventional, cloud-friendly Python stack. The golden rule: separate the three things RPG glued together — presentation, business logic, and data access — so each can evolve independently.
| Layer | Technology | Replaces |
|---|---|---|
| API layer | FastAPI or Django REST Framework — REST endpoints, optionally GraphQL or event streams | DDS Display Files (DSPF) and their 5250 terminal interaction model |
| Service layer | Pure Python modules — no framework, no I/O, fully unit-testable | RPG business logic — calculations, validations, rules |
| Data layer | PostgreSQL + SQLAlchemy or Django ORM; Decimal for all monetary fields | DB2 for i + DDS Physical Files |
| Batch/orchestration | Celery + Redis, or Apache Airflow | CL programmes and IBM i job scheduler |
| Frontend | React or equivalent modern web framework | 5250 green screens |
| Infrastructure | Containerised on AWS (ECS/EKS), CI/CD, observability, IaC | IBM i Power hardware and proprietary job management |
A Structured, Phased Approach: Six Phases That Make Migration Safe
Modernization fails when it is treated as a single heroic rewrite. It succeeds when it is an incremental, measurable programme where every step is reversible and the business keeps running on proven code at all times.
Phase 0 — Discovery, Assessment, and Business Case
You cannot migrate what you cannot see. The first phase is an inventory and a decision framework, not code. The goal is a dependency map, a risk-ranked migration backlog, a target architecture, and a business case with sequencing — all before a line of Python is written.
Cataloguing programmes, files, and jobs can be substantially automated by querying IBM i system catalogs rather than reading source by hand:
-- Objects by type in a library
SELECT OBJTYPE, COUNT(*) AS CNT
FROM TABLE(QSYS2.OBJECT_STATISTICS('APPLIB', '*ALL')) X
GROUP BY OBJTYPE ORDER BY CNT DESC;
-- Programme-to-file and programme-to-programme dependencies
SELECT PROGRAM_NAME, OBJECT_NAME, OBJECT_TYPE
FROM QSYS2.PROGRAM_INFO
WHERE PROGRAM_LIBRARY = 'APPLIB';
For each module, apply the 6 R's decision framework: Retire, Retain, Rehost, Replatform, Refactor, or Rewrite/Rearchitect. Not everything should become Python. Some programmes are dead, some are pure lift-and-shift, and only the high-value business logic deserves a careful refactor.
✓ Phase 0 Output: Dependency map, risk-ranked migration backlog with 6-R decisions, target architecture document, and a costed, sequenced roadmap.
Phase 1 — Choose a Migration Strategy (and Default to the Strangler Fig)
There are three broad strategies. Only one of them is safe for anything non-trivial.
| Strategy | How It Works | Risk Profile |
|---|---|---|
| Big Bang | Rewrite everything, cut over once. Business runs on unproven code from day one. | Highest risk. Avoid for anything non-trivial. One undiscovered edge case in a financial calculation affects every transaction. |
| Parallel Run | New and old systems run side by side on the same inputs. Outputs compared until confidence is high. | High validation confidence. Best for correctness-critical domains (finance, billing, insurance). Slower and more resource-intensive. |
| Strangler Fig (recommended) | A façade sits in front of the legacy system. Migrate one capability at a time, routing each request to either the Python service or the AS/400. The old system is 'strangled' gradually. | Low risk at every step. Every change is reversible. The business runs on proven code until each capability is validated. The recommended default. |
The strangler façade is a thin routing layer — in FastAPI, it is approximately twenty lines. A capability moves into the 'migrated' set only when its Python implementation has passed parallel-run validation. Until then, it continues hitting the AS/400. This is what makes the programme safe: there is never a moment where the whole business depends on unproven code.
✓ Phase 1 Output: Migration strategy selected. Strangler façade deployed. Feature flag routing established between legacy AS/400 and new Python services.
Phase 2 — Migrate the Data Layer First
Data is the foundation. Move it before logic. DDS physical files become PostgreSQL tables, and three conventions must be handled correctly or the migration produces silent data corruption.
| DDS Convention | The Risk If Ignored | Correct PostgreSQL / Python Handling |
|---|---|---|
| Packed decimal (P) / Zoned decimal (S) | Mapping to FLOAT produces penny-level rounding errors across millions of rows. Silent. Fatal to reconciliation. | NUMERIC(n, d) in PostgreSQL. decimal.Decimal in Python ETL. No exceptions. |
| Fixed-width character fields | VARCHAR fields carry trailing spaces from fixed-width padding if not trimmed. Data is technically present but corrupted for display and comparison. | rstrip() on extraction. Normalise empty-after-trim to None/NULL. |
| Dates as packed integers (YYYYMMDD) | Load integer directly into a DATE column: load crashes or produces nonsense dates. Map sentinel value 0 to NULL without explicit handling: real-looking 1900-01-01 rows appear in production. | Explicit conversion function. Sentinel values (0, 99999999) map to NULL. Bad values raise and log — never silently dropped. |
The SQLAlchemy model for a customer master file illustrates the mapping directly — each column comment traces it to the original DDS field name, type, and decimal positions:
class Customer(Base):
__tablename__ = 'customer_master'
customer_id  = Column(Integer, primary_key=True)  # CUSTID 7P0
name         = Column(String(30), nullable=False)  # CUSTNM 30A
credit_limit = Column(Numeric(11, 2), default=0)  # CRDLIMIT 9P2
balance      = Column(Numeric(11, 2), default=0)  # BALANCE 9P2
status       = Column(String(1), default='A')     # STATUS  1A
last_order_dt = Column(Date, nullable=True)Â Â Â Â Â Â Â Â Â # LSTORDDT 8P0
Two rules that prevent silent data corruption on every load: always route money and quantities through Decimal, never float; and validate with row counts and control totals (sum of balances, min/max keys) between source and target after every load. A load that completes without error is not a successful migration. A load whose control totals match the source is.
✓ Phase 2 Output: PostgreSQL schema created. ETL scripts extract, transform, and load with correct decimal handling, character trimming, and date conversion. Control totals reconciled between DB2 for i source and PostgreSQL target.
Phase 3 — Translate the Business Logic
This is the heart of the project. The trap is translating RPG line by line. The goal is to preserve behaviour while producing clean, testable Python. The two things are different.
Fixed-format RPG mixes screen handling, file I/O, and calculation logic in a single monolith, with shared state communicated through indicator variables (*IN99, *IN50, and so on) that any subroutine can set and any other can read. That shared-state model is one of the biggest sources of legacy bugs. The Python translation replaces it with explicit return values and typed exceptions.
Consider an RPG programme that validates and applies an order against a customer's credit limit. It uses CHAIN (a keyed lookup), *IN99 (the not-found indicator), a series of guard checks, and an in-place UPDATE. The Python equivalent:
# services/credit.py
from dataclasses import dataclass
from decimal import Decimal
from datetime import date
class OrderError(Exception):
"""Raised when an order cannot be applied.
Message mirrors legacy MSG codes for parallel-run comparison."""
@dataclass(frozen=True)
class OrderResult:
customer_id: int
new_balance: Decimal
message: str = 'Order applied'
def apply_order(session, customer_id: int,
order_amount: Decimal,
today: date | None = None) -> OrderResult:
today = today or date.today()
customer = session.get(Customer, customer_id)Â Â # CHAIN CUSTREC
if customer is None:Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â # *IN99 = *ON
raise OrderError('Customer not found')
if customer.status != 'A':Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â Â # STATUS <> 'A'
raise OrderError('Customer not active')
new_balance = customer.balance + order_amount  # EVAL NEWBAL
if new_balance > customer.credit_limit:Â Â Â Â Â Â Â Â # IF NEWBAL > CRDLIMIT
raise OrderError('Over credit limit')
customer.balance = new_balance                 # UPDATE CUSTREC
customer.last_order_dt = today
session.commit()
return OrderResult(customer_id, new_balance)
Several deliberate upgrades happen in this translation. RPG's implicit shared state becomes explicit return values and exceptions. The *INxx indicator pattern — set an indicator and check it forty lines later — is eliminated. The keyed CHAIN becomes a primary-key get(). And because the function has no I/O dependencies, it can be tested in isolation:
def test_over_credit_limit(session, make_customer):
make_customer(id=1001, balance='900.00',
credit_limit='1000.00', status='A')
with pytest.raises(OrderError, match='Over credit limit'):
apply_order(session, 1001, Decimal('200.00'))
def test_happy_path(session, make_customer):
make_customer(id=1002, balance='100.00',
credit_limit='1000.00', status='A')
result = apply_order(session, 1002, Decimal('50.00'))
assert result.new_balance == Decimal('150.00')
For free-format RPGLE, which reads closer to a modern language, the mapping is even more direct. A DOW/READ loop that totals open orders for a customer — a SETLL to position the cursor, then a READE loop until end-of-file — collapses into a single set-based SQL expression:
def total_open_orders(session, customer_id: int) -> Decimal:
stmt = (
select(func.coalesce(func.sum(Order.amount), 0))
.where(Order.customer_id == customer_id,
Order.status == 'O')
)
return session.scalar(stmt) or Decimal('0')
The record-at-a-time cursor loop — the defining shape of RPG — is almost always replaceable by a single set-based SQL query. The resulting code is both faster and clearer. Recognising these idioms is where experience pays off, and it is why Reckonsys pairs RPG-literate analysts with Python engineers on translation work.
✓ Phase 3 Output: Python service functions for each translated programme. Characterization test suite covering normal paths, error paths, and boundary conditions. Every *INxx indicator replaced by an explicit return value or exception.
Phase 4 — Replace CL and Batch Jobs
CL programmes orchestrate: they override files, call programmes, and sequence nightly batch. Their modern equivalent is a Celery task chain or an Apache Airflow DAG — which adds retries, monitoring, alerting, and horizontal scale that the IBM i job scheduler never had.
A nightly CL programme that posts orders, ages receivables, and notifies the ops team becomes:
# tasks.py — Celery equivalent of the nightly CL sequence
@app.task(bind=True, max_retries=3, default_retry_delay=60)
def post_orders(self):
...  # translated POSTORDERS logic
@app.task
def age_receivables():
...  # translated AGERECV logic
def run_nightly():
chain(
post_orders.s(),
age_receivables.si(),
notify_ops.s()
).apply_async()
The key capability gains: retries with exponential backoff (no CL equivalent), structured logging and observability into each task's execution, and the ability to scale individual tasks horizontally if processing volume grows.
✓ Phase 4 Output: All CL job sequences replaced by Celery task chains or Airflow DAGs. Batch jobs monitored, retriable, and observable. Job scheduler dependency eliminated.
Phase 5 — Rebuild the Interface
Green-screen DSPF display files become a clean REST API plus a modern frontend. Each migrated capability is exposed through FastAPI or Django REST Framework with Pydantic input validation — including decimal precision validation that mirrors the original field definitions:
class OrderIn(BaseModel):
customer_id: int
amount: condecimal(max_digits=11, decimal_places=2)
@app.post('/orders/apply')
def apply(order: OrderIn, session=Depends(get_session)):
try:
result = apply_order(session, order.customer_id, order.amount)
except OrderError as e:
raise HTTPException(status_code=422, detail=str(e))
return {
'customer_id': result.customer_id,
'new_balance': str(result.new_balance),
'message': result.message
}
The React frontend consumes this API. The former 5250 workflow becomes a responsive web application — and, for the first time, the same business logic is reachable by mobile applications, partner integrations, and analytics pipelines without a middleware shim.
✓ Phase 5 Output: REST API endpoints deployed for each migrated capability. React frontend consuming the API. 5250 green-screen dependency eliminated for migrated capabilities.
Phase 6 — Parallel Run, Validation, and Cutover
Before any capability is trusted with production traffic, run it in shadow mode: send production inputs to both the AS/400 and the new Python service, compare outputs, and log every divergence. Only when the mismatch rate is effectively zero over a meaningful period does the capability's flag flip in the strangler gateway.
def shadow_apply_order(payload):
legacy = call_as400_service(payload)Â Â Â # source of truth
new   = call_python_service(payload)  # new implementation
if legacy != new:
log_divergence(payload, legacy, new) # investigate before cutover
return legacy                           # legacy still authoritative
Cut over capability by capability. Keep the AS/400 available for rollback. Decommission legacy objects only once their Python replacements have carried real production traffic for a defined period. This discipline turns a scary migration into a series of small, reversible steps — each one provably correct before the next begins.
✓ Phase 6 Output: Shadow comparison harness operational. Mismatch rate at zero for each capability before cutover. AS/400 retained as read-only rollback. Legacy objects decommissioned only after sustained production validation.
How Claude Code Compresses the Timeline
Every phase above used to be gated by one scarce resource: a person who could read decades-old RPG and write modern Python. Claude Code — Anthropic's terminal-based agentic coding tool — changes that constraint significantly. Here is where it moves the needle in a modernisation programme.
| Task | What Claude Code Does — and What It Does Not Replace | Task |
|---|---|---|
| Comprehending undocumented legacy code | Point Claude Code at a directory of RPG and DDS source and ask it to explain a programme's business intent, inputs, outputs, and dependencies — including flagging *INxx indicator logic that hides shared state. What used to take weeks of archaeology takes hours. The output becomes the acceptance criteria for the Python rewrite. | Comprehending undocumented legacy code |
| Automated inventory and dependency mapping | Claude Code can script the Phase 0 audit itself — running the QSYS2 catalog queries, parsing the output, and producing the dependency graph and 6-R backlog — rather than a human assembling spreadsheets by hand. | Automated inventory and dependency mapping |
| Translation with a human in the loop | Ask it to translate a programme into a clean, tested Python service and it produces the service function and the pytest suite, following the patterns your team has established. A CLAUDE.md file at the repo root encodes those standards so every translation is consistent — Decimal always, no floats; business logic in services/ as pure functions; every *INxx indicator becomes an explicit return value. | Translation with a human in the loop |
| Generating the characterisation test suite | Legacy code rarely has tests. Claude Code builds the characterisation test suite from sample inputs and outputs and parallel-run logs — so refactoring stays safe. This is particularly valuable because the test suite is what allows aggressive refactoring without fear of undetected regressions. | Generating the characterisation test suite |
| Parallelising the work | For a large codebase, Claude Code can dispatch subagents to work through independent modules concurrently — one investigating batch and CL flows while another translates a cluster of order-management programmes — with a human reviewing each pull request. | Parallelising the work |
| What stays human | Architectural judgement. The 6-R decisions. The parallel-run validation that proves correctness. Edge-case handling surfaced during profiling. Design sign-off. The AI accelerates the tedious 80%. Engineers own the design and the correctness. | What stays human |
Cross-Cutting Concerns You Cannot Skip
| Concern | Why It Cannot Be Deferred | None |
|---|---|---|
| Numeric fidelity | Every packed and zoned decimal must land in Python Decimal with the correct scale. A single float in a financial calculation will eventually produce a penny error that fails reconciliation — and erodes trust in the whole programme. This is non-negotiable and has no exceptions. | None |
| Character encoding | Source data is EBCDIC and fixed-width. Decode to UTF-8 and trim padding on extraction. Test with real names containing special characters — particularly relevant for Indian names in multi-byte character ranges. | None |
| Concurrency semantics | RPG relies on record-level locking. Reproduce the intent with database transactions and, where needed, SELECT ... FOR UPDATE (row locks) or optimistic version columns. Do not assume the ORM default matches the old behaviour. | None |
| Test coverage as the safety net | Legacy code rarely has tests. Build a characterisation test suite from real inputs and outputs before refactoring. That suite, combined with the parallel run, is what lets you refactor aggressively without fear of silent regressions. | None |
| Do not migrate the mess | Dead programmes, unused files, and copy-pasted logic should be retired in Phase 0, not faithfully reproduced in Python. Modernisation is the one chance to pay down decades of accumulated technical debt. Take it. | None |
How Reckonsys Approaches AS/400-to-Python Modernization
Reckonsys is a software engineering firm with deep capability in exactly the technologies this migration requires: Python, AWS, DevOps, data engineering, and QA automation. We structure modernization work to address the risks that sink most legacy programmes — and we use Claude Code to compress the timeline without surrendering control over correctness.
A Technical Consulting engagement runs the Phase 0 audit: automated inventory of programmes, files, and jobs; dependency mapping; and a risk-ranked, 6-R backlog with a costed roadmap. The output is a clear-eyed picture of what to retire, rehost, and refactor — before committing budget to code.
The strangler façade approach means the business keeps running on proven code at every step. Each capability moves to Python only after parallel-run validation confirms zero divergence. Every change is reversible until it is not.
Our engineers use Claude Code to read and document the legacy RPG, generate first-draft Python services and their tests, and build characterisation suites — then apply their own architectural judgement and review to every change. The result is dramatically faster translation without surrendering control over correctness, security, or design.
Translated services are built on modern Python — FastAPI or Django, SQLAlchemy, Celery — with a dedicated data engineering practice handling the DB2 for i to PostgreSQL migration, packed-decimal and date normalisation, and control-total validation. This is covered in detail in Part 2 of this series.
With AWS and DevOps as core competencies, we containerise the new services, set up CI/CD, observability, and infrastructure-as-code — so the modernised system is scalable, monitored, and cheaper to operate than the platform it replaced.
Whether the right model is end-to-end delivery, staff augmentation with RPG-aware Python engineers embedded in your team, or technical consulting and architecture advisory — the engagement flexes to how your organisation wants to work.
Modernizing an AS/400 codebase to Python is not a rewrite. It is a disciplined, phased programme: understand the system, migrate the data, translate the logic into clean and tested services, replace batch and screens, and cut over one safe step at a time.
The AS/400's greatest strength — its decades of embedded business rules, accumulated through thousands of production edge cases — becomes your greatest asset when those rules are lifted into a well-tested Python service layer that the rest of your ecosystem can finally reach. Mobile apps, partner APIs, analytics pipelines, and AI systems can all call the same logic. For the first time.
What has changed is the cost curve. With Claude Code reading the legacy source, drafting translations, and generating the test safety net, the comprehension-and-translation work that once made these projects prohibitively slow is now measured in days, not quarters — while human engineers keep ownership of design and correctness.
The organisations that succeed treat modernization as risk management, not heroics. That is precisely the approach — AI-accelerated, human-governed, backed by Python, cloud, data, and QA depth — that Reckonsys brings to every AS/400 engagement. Conclusion
Let's collaborate to turn your business challenges into AI-powered success stories.
Get Started