Background
“I’ve been programming in C# for many years, even so, I try to keep an eye on new tech being developed, regardless of language – one common choice for new applications these days is Go. Often the easiest way for me to experiment with new technology is to integrate it with some of my existing C# code, hence the desire for this project – besides, I figured I might learn something about building apps in Go in the process.”
That was 2018, and it was a personal itch: learn Go by writing it into something I already knew. Eight years and two full converter architectures later, the desire is still here – but the reasons for having a project like this have gotten a lot better than “I was curious”. More to the point, most of them are now things I can show you instead of argue about, so here is the longer answer to why a Go to C# transpiler?
Why convert Go to C#?
Integration – using Go code from inside .NET. Plenty of organizations are standardized on .NET – build system, CI, deployment, review habits, hiring. When the library they need, or the Go-originated logic they already own, is written in Go, the usual choices are a separate process, a C ABI wrapper, or a hand reimplementation maintained in parallel forever. go2cs is a fourth option: convert it and reference it like any other .NET assembly.
- Consume a Go library. The converted standard library, the
golibruntime and the source generators are published on nuget.org asgo.<pkg>/go.lib/go.gen(versioned1.23.1.<build>fromsrc/version.props).go2cs -recurse=nugetemits a converted project that references them, so pulling in Go-originated code is adding aPackageReference. - Convert a whole application.
-recurse=nugetconverts an app and every third-party package in its import closure, in dependency order, into a solution you can open and build – the go2cs stack restoring from nuget.org, nothing staged on the machine first. The README walks a small CLI built ongithub.com/fatih/color, four dependency projects behind it; it restores, compiles and runs, output matchinggo run.
Either way you hold ordinary .NET projects: the app and its converted third-party packages are yours as C# source; only the standard library, runtime and analyzer arrive pre-built. Want that library as source too – to step into it, or change it? A local deployment stages the whole tree. The emitted C# is identical; only the reference style differs.
Migration and coexistence – with fidelity you can measure. A transpiler whose output merely compiles has proven very little: “it built” can mean only that the converter didn’t crash. So the currency of correctness here is Go’s own tests. For a validated package, the real _test.go suite is converted to C#, built against the converted standard library, run under a Go-semantics test host, and compared verdict for verdict against a clean go test -json baseline – listed only when every Test agrees on both sides and every unexecuted declaration is accounted for.
Validated Test Packages keeps that set – work is actively pushing through the 215 testable Go packages with matching verdicts for converted C# code which define the completion goal denominator, i.e., the converted packages whose Go 1.23.1 sources actually define tests. Underneath sit over 500 behavioral test projects: each a small Go program, transpiled, compiled, byte-compared against a committed golden and, where runnable, executed with its stdout compared against the Go original’s.
This is the argument I care most about: “syntax converter” and “passes the source language’s own test suites, verdict for verdict” are different classes of claim – and the second is reproducible from a clone with one command. That turns gradual migration from a leap of faith into a plan: move a package at a time, and keep the receipts.
Deployment reach – .NET as another place Go source can run. A Go program ships as a single self-contained native binary, and that’s worth keeping. Published with Native AOT, converted C# ships the same way – one self-contained executable with the full converted standard library compiled in – and in the measurements it starts several times faster than the JIT build, with several benchmark rows (channels, the stack-string path, maps) running at parity with Go or better. Go still wins cold start outright, and the AOT binary’s working-set floor carries the whole compiled closure; shrinking both is active optimization surface rather than a semantic cost.
For long-running services the JIT is the better bet – it wins the tight-loop rows AOT gives up. And “slower” is not the whole story: maps, channels and the optimized stack-string path run at parity with Go or faster in both C# columns (map<K,V> rides .NET’s heavily-optimized Dictionary), with Native AOT adding more parity rows. Nor is converted code uniformly fast: string-materialization-heavy work still takes several times Go’s time even after the @string window redesign, and the row measuring interface satisfaction resolved structurally at run time – the thing C# has no native equivalent for – sits over 30× on the JIT and worse under AOT. The table carries its methodology, and the runner refuses to time anything until all three binaries produce identical output.
Readability, so conversion isn’t a one-way door. The output is meant to be read. Receiver methods become extension methods, multiple returns become tuples, embedded structs become promoted fields, and the machinery supplying Go’s semantics – the golib runtime and a set of Roslyn source generators – stays out of sight in partial and generated files. Conversion Strategies maps this construct by construct; the README’s side-by-side table puts real converted standard-library files next to their Go originals so you can judge it yourself.
That matters practically as much as aesthetically: if converted code is legible, a team can adopt it and maintain it – fix a bug in the C# on a Tuesday afternoon – instead of being locked into re-running a transpiler forever, or owning machine output nobody can review.
That said, the tranpile step is fast and cheap – technically this code allows one to “code in Go, run in .NET”, unto here unheard of – this is the truest validation of providing another place where existing Go code can run.
Learning and reference. Because the two languages are mapped construct for construct, the output doubles as a translation dictionary for developers crossing between them. Tour of go2cs puts the official Tour of Go on one side of the window and the generated C# on the other, live, with buttons to transpile, build and run the .NET side. That’s the tool I wanted for myself in 2018.
Honest engineering, which is the only reason to trust any of the above. A tool like this is worth exactly as much as its worst undisclosed defect, so I’d rather publish the defects. When a Go test asserts something a managed runtime provably cannot deliver, it isn’t quietly skipped: the divergence is pinned by exact failure signature in a hand-owned, repo-committed go2cs_test_disclosures.json, so any other failure of that test is still a hard mismatch. Two classes qualify – an exact allocation count, where Go’s compiler stack-allocates and .NET must heap-allocate; and a test asserting from inside its own frame that an object it stopped using is collectible, where Go drops a local at its last use while the CLR reports a frame’s slots live for the frame’s lifetime.
Bugs that would bite an end user get written up in the open, severity first – for instance a rename defect that broke every converted program using time.Second, filed and then updated with its fix and proof. And when a published performance number turns out to be measuring a bug rather than a construct, the superseded table stays alongside the corrected one: an interface row published at 158× Go traced to two runtime defects rather than to the construct; corrected, it reads about 6×. Nothing here is graded on a curve.
What isn’t true yet. All 302 packages of the converted standard library compile as .NET assemblies – but compiling is not the same as working, and I try never to blur the two. Making the library operational is the ongoing Phase 4 effort, and the validated-package table is the honest measure of how far that has gotten. The converted library reproduces Go built with -tags purego, since a managed runtime cannot execute Go’s hand-written assembly and the portable pure-Go paths are therefore the faithful target. Converted code is generally slower than Go, which is expected, and explained next. If any of that changes, this page and the tables it links are where it will show up.
If you code in Go and have occasion to build a C# app, this tool is meant to give you a head start: pull in some of your existing code, and keep using the standard library functions you’re used to.
Converted Code
TL;DR: Do not expect converted C# code to run as fast as the original Go code.
The .NET runtime environment is very different from Go’s. A compiled .NET application consists of an abstract byte-code instruction set called Common Intermediate Language (CIL), compiled to native machine code at runtime by a just-in-time compiler. Go compiles directly to native machine code, so .NET pays that processing as a startup delay – a delay you can remove by pre-compiling the application with a Native AOT deployment.
The philosophy for converted code is C# that is visually similar to the original Go and behaviorally as close to it as possible at a purely “source code” level – code a Go programmer finds usable and understandable inside a C# environment, rather than hyper-focused optimization. That doesn’t mean converted code is slow; it should run as fast as comparable .NET applications, it just may not match the original Go. You don’t have to take my word for the size of the gap: the measured spread across thirteen small workloads – Go, C# on the JIT, and C# published with Native AOT – is published, methodology included, in the performance comparison.
If you are looking for a more binary integration, consider using native compiled Go code directly within your .NET application: export Go functions as C code, include those exports in a DLL, and import the functions in C#.