What Is Go and Why Does It Exist? Go's Philosophy, Toolchain and When to Choose It, for C# Developers
Go is a small, natively compiled language that carries its runtime inside the binary, born at Google as an answer to slow builds and complexity in large codebases. A two-endpoint Bookstore service as a single 9.9 MB Windows .exe that starts in 10 ms and uses 12 MB, a comparison table with ASP.NET Core, go build/vet/fmt/fix/doc, cross-compiling, the six-month release policy and when to choose Go.
Version note: The code in this post was built and run with Go 1.27.4; the same code also compiles with Go 1.26. The ASP.NET Core app used for comparison was built with the .NET 10 SDK 10.0.404.
Guess First
In this series we will write part of the API of Bookstore, the online bookstore used as the shared example across this site's series, in Go. The first step is small: a single-file service with two endpoints. One returns plain text, the other lists three books as JSON. We download no packages; only Go's standard library is involved.
// The first version of the Bookstore API: one file, standard library only
package main
import (
"encoding/json"
"log"
"net/http"
)
type book struct {
ISBN string `json:"isbn"`
Title string `json:"title"`
}
var books = []book{
{ISBN: "9780441172719", Title: "Dune"},
{ISBN: "9780547928227", Title: "The Hobbit"},
{ISBN: "9780141439549", Title: "Middlemarch"},
}
func main() {
http.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello from Bookstore API\n"))
})
http.HandleFunc("GET /api/books", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(books)
})
addr := "127.0.0.1:52710"
log.Printf("bookstore-api listening on http://%s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
I also wrote the same two endpoints as an ASP.NET Core 10 minimal API (CreateSlimBuilder, MapGet and a record Book). Two questions. If you build these services for Windows x64 as a single file, how many MB are the files and what must be installed on the machine to run them? How many milliseconds does each service need to give its first response, and how much memory does it use after 200 requests?
go mod init bookstore
go run .
go: creating new go.mod: module bookstore
2026/11/15 03:48:48 bookstore-api listening on http://127.0.0.1:52710
curl -i http://127.0.0.1:52710/api/books
HTTP/1.1 200 OK
Content-Type: application/json
Date: Sun, 15 Nov 2026 00:48:51 GMT
Content-Length: 135
[{"isbn":"9780441172719","title":"Dune"},{"isbn":"9780547928227","title":"The Hobbit"},{"isbn":"9780141439549","title":"Middlemarch"}]
Building for Windows gave these results:
| Build | File size | Needed on the target machine |
|---|---|---|
Go, go build | 9.9 MB, one .exe | Nothing |
Go, -trimpath -ldflags="-s -w" | 6.8 MB, one .exe | Nothing |
| .NET, framework-dependent | 162 KB .exe + 7 KB .dll + config files | ASP.NET Core Runtime 10 |
| .NET, self-contained single-file | 103.4 MB | Nothing |
| .NET, self-contained single-file + trimmed | 17.4 MB | Nothing |
I measured startup and memory on the machine I wrote this post on, with both apps built for that machine: a small script started the process, timed how long it took until /hello answered, then sent 200 /api/books requests and read the RSS, the amount of physical memory the process held at that moment. I took the median of seven runs. The Go service answered first after 10 ms and used 12.2 MB. The ASP.NET Core service answered first after 203 ms and used 75.5 MB. On Windows the absolute numbers will differ, but the size of the gap sums up this post well: Go is a language designed for small, fast-starting services that compile to native code and carry their runtime inside the binary. The rest of the post covers why, how it differs from C#, and when you should choose it.
Why Does Go Exist?
According to Go's FAQ, Robert Griesemer, Rob Pike and Ken Thompson started sketching the goals of the language on a whiteboard on 21 September 2007. It became open source on 10 November 2009, and Go 1 was released on 28 March 2012. The starting point was everyday trouble at Google: a huge C++ codebase took a long time to build, dependencies had become tangled, and multicore machines were everywhere while most languages did little to help program them safely. The team wanted to combine the ease of writing a dynamic language with the speed and safety of a compiled, statically typed one. One of the goals was to build a large program in a few seconds.
In the language, that goal turned into "few features". Go has no classes, no inheritance, no exceptions, no operator overloading, no method overloading and no default parameters. The only loop is for. Generics arrived in 2022 with Go 1.18; C# has had generics since C# 2.0 in 2005. These gaps are design decisions: before adding a feature, the Go team weighs the cost it puts on the person reading the code. The result is that when you open a Go file, you can rarely tell who wrote it. Someone who has spent a week in one Go project reads another Go project's code comfortably.
The second important decision is the Go 1 compatibility promise. Code written for Go 1 keeps compiling and running on later Go 1.x releases, with a few exceptions such as security fixes. Fixes that change behaviour are controlled through GODEBUG settings and the go line in go.mod; we will see this in 02. If you are used to reading breaking change lists for every major .NET upgrade, you will find that most Go upgrades amount to changing the toolchain version.
Go for a C# Developer: Side by Side
In the C# Fundamentals series post "What Are C# and .NET?" we separated the language, the runtime and the SDK. In Go all three come in a single download: the compiler, the standard library and the go command. A summary of the differences:
| Topic | C# / .NET | Go |
|---|---|---|
| Execution model | Compiled to IL, runs on the CLR with a JIT; Native AOT is an option | Compiled straight to native code, no VM |
| Memory | Garbage collector | Garbage collector, the runtime lives inside the binary |
| Deployment | Machine with the runtime installed, or a self-contained publish | One static binary |
| Types | class, struct, record, inheritance | structs and methods; no inheritance, embedding instead |
| Interfaces | Implemented explicitly (: IRepository) | Implicit: a type satisfies an interface if its methods match |
| Errors | Exceptions, try/catch | error return values, if err != nil |
| Concurrency | Task, async/await | Goroutines and channels, no function colouring |
| Null | Nullable reference types | Zero values; nil for pointers, maps, slices, interfaces |
| Packages | NuGet, .csproj | Modules, go.mod and go.sum |
| Code style | .editorconfig, analyzers | gofmt, one official format |
"No function colouring" deserves a few words. In C#, once a method becomes async, its callers usually become async too; async code spreads up the call chain. Go has no such split. Every function is written as a normal function and looks as if it blocks while waiting for I/O, but the runtime hands the thread to another goroutine in the meantime. The job done by the state machine we described in the C# Asynchronous Programming series post on Task is done in Go by the runtime's scheduler. We will look at this in detail in 07.
The First Program, Line by Line
package main and func main() say that this file is a runnable program, not a library. It is the counterpart of C#'s top-level statements. The three packages in the import block come from the standard library; we added nothing the way we would with NuGet. go mod init bookstore created a go.mod file. The bookstore module in Go is the counterpart of the Bookstore.Api project on the C# side; from 02 on, namespaces like Bookstore.Catalog will become packages like internal/catalog.
The book struct starts with a lowercase letter. In Go, the first letter decides whether a name is visible outside its package: uppercase is exported, lowercase stays inside the package. There is no public or private keyword. The fields ISBN and Title are uppercase because the encoding/json package can only see exported fields. The `json:"isbn"` next to a field is a struct tag, the counterpart of C#'s [JsonPropertyName("isbn")] attribute.
The pattern in http.HandleFunc("GET /api/books", ...) includes the HTTP method. The standard library router has supported this since Go 1.22; it does the same job as app.MapGet("/api/books", ...) in a minimal API. That is why a POST /api/books request returned 405 and the undefined / returned 404. The handler signature is func(w http.ResponseWriter, r *http.Request): you write the response to w and read the request from r. You can think of it as ASP.NET Core's HttpContext split in two.
I used 127.0.0.1:52710 as the address. Had you written just :8080, the service would listen on every network interface, and on Windows the Defender Firewall permission dialog would pop up on the first run. Binding to the loopback address during development avoids that and keeps the service off the local network. 52710 is unlikely to clash with another application, and we will use it throughout the series.
I left two things in this code on purpose. w.Write and Encode each return an error, and I ignored both; in Go an error is a return value, and ignoring it is a choice. We will fix that in 05. http.ListenAndServe starts a server without timeouts; fine for a try-out, not for production. In 09 we will set up an http.Server with its timeouts.
One Tool: the go Command
In .NET, the dotnet CLI, analyzers, the formatter and the test runner come from different pieces. In Go they are all subcommands of one go command: go build compiles, go run compiles and runs, go test runs tests, go vet finds suspicious code, go fmt formats, go doc shows documentation, go fix rewrites code into the idioms of newer releases, and go mod manages dependencies. Let's try them on a small file that is badly written on purpose:
// A deliberately badly formatted and buggy file
package main
import "fmt"
func describe(v interface{}) string {
return fmt.Sprintf("%v", v)
}
func main() {
stock := map[string]int{"Dune": 3, "Emma": 0}
for i := 0; i < 3; i++ {
fmt.Printf("round %d: Dune stock is %s\n", i, stock["Dune"])
}
fmt.Println(describe(stock))
}
gofmt -l .
go vet ./...
main.go
main.go:12:41: fmt.Printf format %s has arg stock["Dune"] of wrong type int
gofmt -l listed the badly formatted files. go vet found a bug the compiler does not catch: %s expects a string and we passed an int. The code compiles and runs, but the output contains the line round 0: Dune stock is %!s(int=3). go test runs a subset of vet on its own, so you often notice bugs like this while testing.
go fix was rewritten in Go 1.26 and is now the home of the modernizers, fixes that rewrite code into the language's newer idioms. With -diff you can see what it would change:
go fix -diff ./...
--- /home/user/tools01/main.go (old)
+++ /home/user/tools01/main.go (new)
@@ -2,14 +2,14 @@
import "fmt"
-func describe(v interface{}) string {
- return fmt.Sprintf("%v", v)
+func describe(v any) string {
+ return fmt.Sprintf("%v", v)
}
func main() {
- stock := map[string]int{"Dune": 3, "Emma": 0}
- for i := 0; i < 3; i++ {
- fmt.Printf("round %d: Dune stock is %s\n", i, stock["Dune"])
- }
- fmt.Println(describe(stock))
+ stock := map[string]int{"Dune": 3, "Emma": 0}
+ for i := range 3 {
+ fmt.Printf("round %d: Dune stock is %s\n", i, stock["Dune"])
+ }
+ fmt.Println(describe(stock))
}
It wrote any instead of interface{}, for i := range 3 instead of the counting loop, and formatted the file as well. The indentation became tabs: that is gofmt's one rule and it is not up for debate. Nobody in the Go world argues about tabs versus spaces or where the braces go, because everyone uses the same tool. In these posts I show code with four spaces for readability, but the files contain tabs. It did not touch the %s bug vet found; go fix only applies fixes that do not change behaviour.
Documentation lives in the same tool. go doc shows the description of a package or function in the terminal:
go doc net/http.HandleFunc
package http // import "net/http"
func HandleFunc(pattern string, handler func(ResponseWriter, *Request))
HandleFunc registers the handler function for the given pattern in
DefaultServeMux. The documentation for ServeMux explains how patterns are
matched.
One Binary and Cross-Compiling
I built the .exe from Guess First on another operating system, not on Windows. In Go, the target platform is chosen with two environment variables, GOOS and GOARCH. No extra SDK or runtime pack needs to be downloaded:
GOOS=windows GOARCH=amd64 go build -o bin/bookstore-api.exe .
go version -m bin/bookstore-api.exe
bin/bookstore-api.exe: go1.27.4
path bookstore
mod bookstore (devel)
build -buildmode=exe
build -compiler=gc
build CGO_ENABLED=0
build GOARCH=amd64
build GOOS=windows
build GOAMD64=v1
build vcs=git
build vcs.modified=true
go version -m reads the build information embedded in the binary: which Go version, which platform and which settings it was built with. The CGO_ENABLED=0 line matters. When cross-compiling, cgo, the mechanism for calling C code from Go, is off by default, so the binary depends on no C library. That is why you can copy the file to Windows and run it directly. If you work on Windows and need a build for a Linux server, in PowerShell you write $env:GOOS="linux"; $env:GOARCH="amd64"; go build -o bin/bookstore-api . (I did not try this line on Windows). I did not run the .exe from this post on Windows either; I only built it and read the information inside.
On the .NET side you reach the same result with a self-contained publish, but then the runtime itself goes into the package and the file passes 100 MB. Trimming brings it down to 17.4 MB. Native AOT produces a smaller file, but building for Windows requires building on Windows, so I did not measure it here. In Go, a small, self-contained binary is the default; no extra setting is needed. -ldflags="-s -w" stripped the debug symbols and brought the file down to 6.8 MB; -trimpath removes the build machine's file paths from the binary. We will cover both with the production build in 13.
Release Policy: A Release Every Six Months, Two Releases Supported
Go ships two major releases a year, in February and August. Go 1.26 came out on 10 February 2026, Go 1.27 on 19 August 2026. The rule on the Release History page is short: each major release is supported until there are two newer major releases. So today 1.27 and 1.26 are supported; when 1.28 arrives in February 2027, support for 1.26 ends. Security and critical bug fixes reach supported releases as minor releases. The 1.27.4 I used for this post and 1.26.11, released the same day, are the security releases of 10 November 2026.
.NET's LTS/STS split does not exist in Go. Each release is supported for about a year, so upgrading the toolchain at least once a year is routine work in Go projects. Thanks to the compatibility promise, these upgrades are usually painless. This series is built on Go 1.27, but I write the examples so that they also compile on 1.26; when I use something specific to 1.27, I will say so.
When to Choose Go, and When Not To
Go is strongest at network services and infrastructure tools. Docker, Kubernetes, Terraform and Prometheus are written in Go; much of the cloud infrastructure ecosystem speaks Go. The technical reasons are the ones we saw above: single-binary deployment, low memory, fast startup, easy cross-compiling and a near-production-grade HTTP server in the standard library. CLI tools suit Go for the same reason: the user downloads one file and runs it.
Where Go is weaker is also clear. A rich domain model, meaning business logic with many rules, state transitions and relationships, is more comfortable to write with C#'s type system and Entity Framework Core. Go has no widespread ORM culture; the community prefers hand-written SQL or tools that generate code from SQL (in 11 we will compare this with Dapper). If the whole team is deep in .NET and the service already works well, switching languages just to save memory is rarely worth it.
For Bookstore specifically, the rule-heavy core such as ordering, payment and the institutional approval flow stays in .NET. A small service that pulls stock data from the warehouse and distributes it, an endpoint that receives webhooks from the shipping company, or a command-line tool used by the back office team are good candidates for Go. In this series we write part of the Bookstore API in Go to learn the language, because the best way to learn a language is to solve a familiar problem with it.
In Production
Pin the toolchain version and keep it current. Since each major release is supported for about a year, the Go version your CI uses must be written down explicitly somewhere. In Go, that place is the go and toolchain lines in go.mod; we will look at both in detail in 02. Apply security releases regularly, like .NET's Patch Tuesday updates; 1.27.4 is itself a release with security fixes for the standard library and the toolchain.
Know what goes into the binary. The output of go version -m lets you find out later which Go version and which dependencies a binary running in production was built with. Security scanners read this information too.
Treat the measurements with care. 10 ms and 12 MB are impressive numbers, but they belong to a service with two endpoints. A real service grows with database connections, logs and cache. If these numbers are your reason for choosing Go, repeat the measurement with a realistic version of your own service.
Common Pitfalls
Trying to write Go like C#. In the first weeks it is common to open an interface for everything, build deep package hierarchies and use panic everywhere instead of exceptions. Go prefers plain code; throughout the series I will put the C# habit and the Go idiom side by side.
Ignoring returned errors. In the code above we did not look at the error returned by Encode. The compiler allows it because we never use the return value. But if you assign a value to a variable and never use it, the compiler fails: in Go, an unused variable and an unused import are compile errors.
Starting the service on an address like :8080 and listening on every interface. On a development machine the service quietly becomes reachable from the local network. In production, too, the address to listen on should come from configuration; we will set that up in 13.
Using go run in production. go run compiles on every start, runs the result from a temporary folder and needs the Go toolchain on the machine. Always put the output of go build on the server.
A Small Experiment
★☆☆ Add an endpoint GET /api/books/count to main.go that returns the number of books as plain text. len(books) returns an int, while w.Write wants a []byte. What does the compiler say? Solve it with fmt.Fprintf(w, "%d\n", len(books)).
★★☆ Build the service for your own operating system and also for Linux (GOOS=linux GOARCH=amd64). Compare the sizes of the two files and run go version -m on both. Which lines differ?
★★★ Write the ASP.NET Core minimal API from Guess First yourself and measure the startup time and memory of both services on your own machine. Then publish the .NET side with Native AOT (-p:PublishAot=true; on Windows it needs Visual Studio's C++ build tools) and measure again. How much of the gap closes?
We have seen what Go is and why it exists. In the next post we will open go.mod and look at what each line means: the module path, the go and toolchain lines, go.sum and the module proxy. Then we will move the single-file service into a real project layout with cmd/bookstore-api and internal/catalog.