Why I switched from a polylingual stack

Jesse James Richard
|
Jun 1, 2026
|
7 min read
#Architecture
#Testing

For the first few months, the backend was written in two languages. The AI service, the file processor, the error tracker, and the MCP server were Python. Everything else was TypeScript. Over about a week in May I rewrote all four in TypeScript and deleted the Python. The reasons I started with the split were mostly wrong. The reason I ended it is the one worth writing down.

Why it was Python in the first place

It started with files. A file processor does heavy work. It compresses and resizes images. It compresses and resizes video. It pulls text out of audio and out of video. It runs OCR over images and PDFs. In early 2026 I assumed this kind of load belonged in Python. I believed it. I did not trust a Node process to hold up under it.

The file service also built the embeddings for every file, which tied it closely to the AI work. The AI service became Python to match. The MCP server was an extension of the AI service. It followed. Three services in Python because the first one was. And the first one was Python because of an assumption about media processing I never checked.

The assumption was wrong

The file service came down to five pieces. Images went through Pillow. OCR went through Tesseract. Video and audio went through ffmpeg. PDFs and documents went through PyMuPDF. The embeddings were an HTTP call to another service.

How it was done
Python's edge

Images

Pillow

Slower than Node's libvips

OCR

Tesseract, a native C program

None, any language calls it

Video and audio

ffmpeg, a native binary

None, identical either way

PDFs and documents

PyMuPDF

Node has a capable equivalent

Embeddings

An HTTP call to another service

None, no language at all

Almost none of that is Python. Tesseract and ffmpeg are native programs written in C. A Python service uses them by shelling out to the same binaries any language would call. PyMuPDF wraps a C library that has a capable equivalent in Node. The embeddings were a network request, which has no language at all. The only piece that was genuinely a Python library was Pillow, the slow option. Node's image library is built on libvips, which runs several times faster than Pillow on less memory. For the one task where the language mattered, I picked the weaker one.

The clearest evidence is in the deploy configuration.

There is a fair version of the old worry, worth stating so the lesson is not too neat. Python does still lead for a certain tier of media work. Transcribing audio at scale on a GPU, running the modern deep-learning OCR models, doing heavy computer vision, serving embeddings at high throughput, all of that has a genuine Python advantage, because that is where the mature bindings live. I was not doing any of it. My work was deterministic media, resize and transcode and read the text. For that, the gap between the two languages has closed. The worry pointed in the right direction for the wrong reason. The problem was never that the language was too slow. It was that a few years ago the Node bindings for the frontier engines lagged. For ordinary media work, they no longer do.

The reason it had to change

So Python bought nothing. That alone is an argument for tidiness, not for a rewrite. The rewrite happened because of what the second language was costing on the other side of the codebase, at the generation step.

The platform is schema-driven. A single API schema generates a large amount of the code around it, the request hooks, the validation, the query and cache layers, the tests, the mocks. The shape is defined once. Everything downstream is generated from that one definition. Nothing can drift out of agreement, because there is only one source. This is one of the oldest principles in the codebase. It is what lets one person maintain this many moving parts.

I wanted the AI service inside that guarantee. When the AI builds a page, it works with the same blocks and sections the rest of the platform defines in TypeScript. Those types should be enforced on the AI side too, generated from the one definition, not a second copy kept by hand. Defined once, shared through the generated package, read by every consumer from a single source.

A polylingual backend cannot have that. To feed a Python service, the generator has to emit Python as well as TypeScript. The two outputs are not cosmetic variants of each other. They are different files that follow different rules. The monorepo's dependency graph resolves cleanly for TypeScript and not for Python. So the generated Python cannot live in the shared generated package where everything else does. It has to be copied into each Python service that consumes it. And Python's naming conventions do not match the design guidelines the rest of the codebase holds to. The generated code does not even read the same. The generation step had become polylingual itself, producing two kinds of output, shaped differently, stored in different places, one of them breaking the rule that generated code belongs only in the generated package.

Moving the AI service to TypeScript dissolved all of it. One generator, one output, one package, one set of rules. The guarantee that already covered the rest of the platform now covered the AI work too, for free, because there was no longer a second language standing outside it.

The second layer nobody sees

The generation problem was the sharp edge. It was not the only tax. A second language is a second everything. The Python services ran their own test framework and their own test cache, separate from the vitest suite and the cache the TypeScript side depends on. Two runners, two caches, two lanes through CI, a whole parallel testing apparatus that existed only because half the backend spoke a different language. Keeping one test suite fast and trustworthy is hard enough. I was paying to do it twice.

The rest followed on their own

Once the AI service was TypeScript, the others did not need a separate argument. The MCP server is an extension of the AI service and a consumer of the same generated types, the ones that drive how its tools are assembled. It moved for the same reason the AI service did. The file service, now the last customer-facing Python service and not tied to the generator at all, moved for plainer reasons, readability and one less context to switch into. The error tracker followed the same way.

Standardizing the language paid one more dividend across all of them. The contracts for what a service accepts and sends, and the shared middleware around them, could be written once and reused everywhere instead of kept in two dialects. The polylingual monorepo became a monolingual one. A monolingual monorepo is a much smaller system to hold in your head.

Would I do it again

Probably not. The instinct that put me here, right tool for the job, is a good one. I would not want to talk anyone out of it in general. It comes down to how large the gap really is. If my file service had needed GPU transcription or deep-learning OCR or serious computer vision, the Python advantage would have been large enough to carry a second language and everything it costs. It did not need any of that. The gap I was paying for was imaginary. The costs were not.

The gap I was paying for was imaginary. The costs were not.

That is the part I would tell someone starting out. Right tool for the job is a real principle. But in a codebase where one schema generates the code around it, a single language is itself a tool, and usually the more valuable one. The second language does not announce its cost up front. It charges you slowly, at the generation step, in the test suite, in the contracts between services, in every place the two halves have to be kept in agreement by hand. A monolingual monorepo is my default now. It would take a much larger gap than the one I imagined to move me off it.

Have questions?

If you're interested in my work or Giant Context, contact me!

Contact Jesse
More
Sitemap
Privacy Policy
Terms of Service
Cookie Policy
Hosted on
Why I switched from a polylingual stack | Jesse James Richard