Skip to content

Migrating Between Languages and Frameworks With AI

5 min read · updated August 3, 2026

Translation is the task language models are most obviously suited to, and a port is not translation. It is a bet that the new system behaves like the old one, and the only thing that settles a bet is an oracle.

Without an oracle it is a rewrite

A migration with no way to check equivalence is a rewrite that has been mis-sold, and rewrites fail for well-known reasons that have nothing to do with AI. So the first question is not which model or which framework — it is: what will tell me the new implementation is wrong?

Three answers, in descending order of how much they are worth:

  • An existing test suite that actually constrains behaviour. Rare, and if you have it, this is a manageable project.
  • Recorded production traffic. Capture real requests and their responses from the current system for a representative window, then replay them at both implementations and diff. This is almost always available and almost always skipped; it is also the only oracle that covers the inputs you did not think of. Replaying recorded traffic is worth reading before you build the harness.
  • Characterisation tests generated from the old implementation. Feed the old code its own inputs, record outputs, assert them as-is. They encode bugs faithfully, which is the point.

If none of the three is achievable for a component, do not migrate that component with a model. Migrate it by hand, slowly, or leave it.

Five stages, in this order

1. Freeze behaviour

Build the oracle before touching anything. Record fixtures, commit them, and make them runnable against the old system so you know the harness itself is correct — a replay harness that reports 100% match against the system it recorded from is the only version you can trust.

2. Port the pure core

Functions with no I/O translate most mechanically and verify most cheaply: same input, same output, no environment. This is where a model earns its keep, and it is where you should be doing hundreds of files. Keep the same function names as the old system for the duration, even if they are unidiomatic in the target language — renaming and porting in one step destroys your ability to diff.

Rename in a separate commit afterwards, with a codemod. See the codemod argument.

3. Run both and diff

Before the new implementation serves anything, put it behind the old one in shadow: every request goes to both, the old one answers, the difference is logged. This finds the class of mismatch that fixtures never will, because production inputs are stranger than anyone’s fixtures.

# a shadow comparison worth having: normalise before diffing, and count
# by shape rather than logging every mismatch, or you drown in noise.
mismatch_kind = classify(old_resp, new_resp)   # 'float_precision' | 'key_order'
                                               # | 'null_vs_missing' | 'value'
metrics.increment("migration.mismatch", tags=[f"kind:{mismatch_kind}"])

Most early mismatches are formatting: key order, trailing zeros, timestamp precision. Classify and normalise them deliberately rather than one by one, or the real mismatches stay buried.

4. Move the I/O

Database access, HTTP clients, queues. This is where the concurrency and error-model differences bite, and it is the stage to do by hand with the model as a reference rather than as an author.

5. Contract

Cut traffic over per route or per tenant, keep the old path warm for a release, then delete. Deleting is a real stage; a migration that leaves both systems alive has doubled the maintenance rather than reduced it.

What does not survive the boundary

Algorithms and data structures translate almost perfectly. These do not, and a model will translate them plausibly, which is worse than translating them badly.

Numeric width — the one that bites hardest

A 64-bit integer id moving through JavaScript is the classic. All numbers in JS are doubles, so integers above 253 are not representable:

> Number.MAX_SAFE_INTEGER
9007199254740991
> JSON.parse('{"id": 9007199254740993}').id
9007199254740992          // silently off by one, and it round-trips wrong
> 9007199254740993 === 9007199254740992
true

A Java long, a Postgres bigint id or a Twitter-style snowflake all fail this way, and they fail on the high ids only — so the port passes every test written against a fresh dev database and corrupts the oldest and largest customer in production. Search for integer widths explicitly during any migration into or out of JavaScript, and carry large ids as strings.

The rest of the list

  • Error model. Exceptions to Result/error returns is a control-flow rewrite, not a translation. A model will frequently produce code that swallows what used to propagate — check every conversion point.
  • Nullability. Absent, null and zero-value are three distinct things in some languages and two in others. JSON round-trips are where this surfaces.
  • Concurrency. Threads to goroutines to async/await are different models with different failure modes. Shared mutable state that was safe under a GIL is not safe in Go.
  • Date and time. Naive versus aware datetimes, DST handling, week numbering, and whether a “day” is 24 hours.
  • String and collation semantics. Byte strings versus code points versus grapheme clusters; case-insensitive comparison is locale-dependent and differs between runtimes.
  • Sort stability and map ordering. Code that accidentally relied on insertion-ordered maps or a stable sort produces different output, and the diff looks like noise.

Prompting with your own exemplar

The instruction that changes output most is not a style guide, it is a worked example from the target codebase. Give the model one file that has already been migrated and reviewed, plus the source file, plus the target project’s conventions. It will copy the error handling, the import layout, the logging style and the test structure from the exemplar far more faithfully than from any description.

Migrate the first three files by hand for exactly this reason. They are not overhead; they are the prompt.

Two instructions worth adding verbatim: preserve behaviour including behaviour you believe is a bug, and list anything you believe is a bug separately — the list is a genuinely useful artefact — and do not add features, logging, validation or error handling that the source does not have, because unrequested additions are what make the shadow diff unreadable.

Reading the diff for trouble

  • Line-count parity. A file that shrinks by 40% in translation has usually lost a branch, not gained elegance. Look at what is missing before admiring what is there.
  • New comments. A comment in the ported file that has no counterpart in the source is the model explaining an assumption it made. Those are the lines to read first.
  • Vanished branches. Count if, case and catch in both files. It is crude, it is a thirty-second check, and it catches dropped edge cases better than reading does.
  • Silently widened types. An i32 that became an int, a decimal that became a float. Money in floats is its own disaster — keep currency in integers.
Migrating Between Languages and Frameworks With AI · Multigrid