Porting Code Between Programming Languages With LLMs in November 2026: Port the Tests First, Translate Behavior Instead of Syntax, and Compare Outputs Side by Side
Moving code from one language to another is one of the jobs people most often hand to an LLM: a Python script that needs to become a Go service, an old PHP helper being rewritten in TypeScript, a Java utility class headed for Kotlin. Models are genuinely good at this, because they have seen a lot of code in every popular language. They are also good at producing a port that compiles, looks idiomatic, and quietly behaves differently from the original. This guide walks through a workflow that keeps the speed and catches the differences before your users do.
The Rosetta Stone carries one decree in three scripts. A good port should say the same thing in a new language, not just look similar. Image: Rosetta Stone by Hans Hillewaert, CC BY-SA 4.0, via Wikimedia Commons.
Why ports go wrong even when the code looks right
Most porting bugs are not syntax mistakes. They come from places where two languages give the same-looking code different meanings. A few common ones:
- Integer and float math. In Python 3,
7 / 2is3.5; in Go, Java, or C with integers it is3. Python's//floors toward negative infinity, while integer division in Go, Java, C, and JavaScript'sMath.trunctruncates toward zero, so-7 // 2is-4in Python and-3in Go. - Integer size. Python integers never overflow. Java's
intwraps at about 2.1 billion, and JavaScript numbers lose exact integer precision above 253. - Strings. "Length" can mean bytes, UTF-16 code units, or code points depending on the language.
len("é")in Go counts bytes; JavaScript's.lengthcounts UTF-16 units; Python counts code points. - Sorting and maps. Some sorts are stable and some are not. Go map iteration order is deliberately randomized, while Python dicts keep insertion order.
- Errors and nulls. An exception that bubbles up in Python may become a returned error in Go that the port silently ignores, or a
nullthat turns into a crash three calls later. - Dates, regex, and rounding. Regex flavors differ, time zone handling differs, and rounding modes differ (Python's
round(2.5)is2, using banker's rounding).
A model translating line by line will often keep the shape of the code and miss exactly these details. The fix is not a cleverer prompt. It is a process where the original program's behavior, not its text, is the thing you are copying.
Step 1: Pin the original behavior before you touch anything
Before asking for a single line of the new language, make sure you can describe what the old code does in a way a machine can check.
- Collect existing tests. If the original has a test suite, that is your starting specification.
- Record real inputs and outputs. If tests are thin, run the original against a set of realistic inputs and save the results as fixture files: input on one side, exact output on the other. Include the boring cases and the ugly ones: empty input, very large numbers, negative numbers, non-ASCII text, missing fields.
- Write down side effects. Note which files it writes, which environment variables it reads, what it logs, and what exit codes it uses. These are part of the behavior too.
You can ask the model to help here: "Read this function and list every input category and edge case that would exercise a different branch." That list is useful. The fixture outputs, however, must come from actually running the original code, never from the model's guess about what it would print.
Step 2: Port the tests first
Have the model translate the test suite and fixture loader into the target language before it translates the code under test. This sounds backward, but it gives you a target that cannot drift. A prompt that works well:
Here is a pytest file and the JSON fixtures it uses.
Translate the tests to Go's testing package.
Keep every test case and every expected value exactly as written.
Do not add, remove, or "fix" any expected value.
If a test depends on Python-specific behavior, keep the test
and add a comment explaining the dependency.
Then diff the list of test names between the old and new suites. If the counts do not match, find out why before going further. A dropped test is the cheapest place to lose behavior.
Step 3: Translate in small units, and give the model the right context
Port one module or one group of related functions at a time, not the whole repository in one request. For each chunk, give the model:
- The source code for that chunk and the signatures of anything it calls.
- The exact target versions, for example "Go 1.23, standard library only" or "TypeScript 5 with strict mode, Node 22."
- Your library choices, so it does not invent dependencies. If you want a specific HTTP client or decimal library, name it.
- An instruction to preserve behavior over style: "Match the original's integer division, rounding, string length, and error behavior exactly. Where the idiomatic target-language version would behave differently, keep the original behavior and leave a comment."
Also ask for a short list of "semantic differences I had to handle" at the end of each answer. A model that has to name where Python's floor division or dict ordering mattered is more likely to have handled it, and the list tells you where to look during review. Treat the list as a pointer, not a guarantee; check those spots yourself.
Watch for invented library functions
When a source library has no direct equivalent, models sometimes call a function that sounds right but does not exist in the target ecosystem, or exists with different arguments. The compiler catches most of these in statically typed languages. In dynamic languages, run the code paths in tests before trusting them, and check unfamiliar package names against the real registry before installing anything.
At a break of gauge, every load had to be moved by hand from one system to another. Check that nothing falls off along the way. Image: Labourers shovelling barytes ore from narrow to broad gauge wagons, Terowie railway station, 1909 by an unknown photographer (State Library of South Australia), public domain, via Wikimedia Commons.
Step 4: Compare outputs side by side
Passing the ported unit tests is necessary but not enough, because the tests only cover what someone thought to test. Add a differential check: run the old and new programs on the same inputs and compare the results.
# run both versions over every fixture and diff the results
for f in fixtures/*.json; do
python old/main.py < "$f" > /tmp/old.out
./new/main < "$f" > /tmp/new.out
if ! diff -q /tmp/old.out /tmp/new.out > /dev/null; then
echo "MISMATCH: $f"
fi
done
For functions rather than whole programs, a property-based testing library (Hypothesis in Python, fast-check in JavaScript, jqwik in Java) can generate thousands of random inputs. Feed the same generated inputs to both implementations, for example by exporting them to a file, and compare. Random inputs are very good at finding the negative-number division and Unicode cases that hand-written tests skip.
When there is a mismatch, paste both outputs, the input, and the two relevant functions into the model and ask it to explain the difference before it proposes a fix. Then decide: is the old behavior a feature to keep, or a bug you are glad to drop? That is a product decision, so make it on purpose and write it down, rather than letting the port quietly change it.
Step 5: Make it idiomatic only after it is correct
Once the new version matches the old one, you can ask the model to clean it up: use the target language's error handling conventions, standard collections, and naming. Do this as a separate pass, with the full test suite and differential check run again afterward. Mixing "translate" and "improve" in one step is how behavior changes slip in unnoticed, because every line of the diff looks like an intentional improvement.
Step 6: Cut over gradually
For anything running in production, avoid a single switch. Practical options:
- Shadow mode. Send real requests to both versions, serve the old version's answer, and log any difference.
- Percentage rollout. Route a small share of traffic to the new version and watch error rates and output checks.
- Keep the old version runnable for a while, so you can reproduce a disputed result after the cutover.
A short checklist
- Fixtures are generated by running the original code, not by the model.
- Tests are ported first, with every case and expected value unchanged.
- Each prompt names the target version, allowed libraries, and "preserve behavior over style."
- Division, rounding, integer size, string length, ordering, and error paths have been checked by hand.
- A differential run over real and random inputs shows no unexplained mismatches.
- Idiomatic cleanup happens in a separate pass, followed by the same checks.
- Any intentional behavior change is written down.
An LLM can turn a multi-week port into a few days of work, but only if you treat its output as a first draft that has to prove it behaves like the original. Port the tests first, compare real outputs, and let the differences, not the look of the code, tell you when you are done.
Comments
Post a Comment