Go vs C#

A Practical Comparison for Backend Developers

Go offers speed and simplicity for cloud services, while C# provides rich tooling and features for enterprise applications.

The deployment friction test

You are building a backend service that processes webhooks and forwards them to a database. Your team knows C# well. The infrastructure lead suggests Go because the current C# service takes four minutes to start up and consumes 500MB of RAM just to idle. You stare at the two options. The choice isn't about which language is superior. It's about which tool matches the shape of the problem and the constraints of your team.

Go reduces friction by limiting choices. You write code, run go build, and get a single binary that runs anywhere. No runtime to install. No dependencies to manage at deploy time. C# embraces complexity to give you control. You get a massive standard library, powerful IDE support, and a type system that can model complex domain logic precisely. The trade-off is that C# requires more setup and carries a heavier runtime footprint.

Simplicity versus expressiveness

Go feels like a well-tuned command line tool. The language has fewer keywords than C. It lacks generics until version 1.18, and even then, generics are conservative. There is no inheritance. There are no properties. There are no operators to overload. The standard library is the only dependency you need for most tasks.

C# feels like a rich ecosystem. The language evolves rapidly with features like pattern matching, records, and top-level statements. The type system supports advanced abstractions. The ecosystem includes NuGet packages for almost every problem. The IDE tooling is deep, with refactoring, navigation, and debugging features that rival any environment.

The difference shows up in how you write code. Go code tends to be verbose in error handling but concise in structure. C# code tends to be concise in error handling but can become verbose in configuration and boilerplate.

Minimal example: Hello World

Here is a simple HTTP server in both languages. The Go example uses only the standard library. The C# example uses ASP.NET Core, which is the standard framework for web development.

package main

import "net/http"

// HandleRoot writes a greeting to the HTTP response.
func HandleRoot(w http.ResponseWriter, r *http.Request) {
    // Write sends the response body. The error is ignored here for brevity,
    // but in production code you should check it to detect write failures.
    w.Write([]byte("Hello from Go"))
}

func main() {
    // HandleFunc registers the handler for the root path.
    // The handler is a function that matches the http.HandlerFunc signature.
    http.HandleFunc("/", HandleRoot)
    // ListenAndServe starts the server on port 8080.
    // It blocks until the program exits or an unrecoverable error occurs.
    http.ListenAndServe(":8080", nil)
}
using Microsoft.AspNetCore.Builder;

// Program entry point for ASP.NET Core minimal API.
var builder = WebApplication.CreateBuilder();
var app = builder.Build();

// MapGet registers a GET endpoint that returns a string.
// The framework handles serialization and response writing.
app.MapGet("/", () => "Hello from C#");

// Run starts the web host and blocks the main thread.
// It configures Kestrel and sets up the request pipeline.
app.Run();

The Go code is a single file. You can run it with go run main.go. No project file is needed. No package restore is needed. The C# code requires a .csproj file and a dotnet restore to fetch the ASP.NET Core framework. The Go standard library is built-in. The C# framework is external.

Go code looks the same everywhere. The community uses gofmt to format code. The tool runs automatically in most editors. You don't argue about indentation or brace style. The tool decides. C# has formatting rules, but they can vary between projects. You configure .editorconfig to enforce style.

Under the hood: Compilation and runtime

When you run go build, the compiler produces a single executable file. That file contains the code, the standard library, and all dependencies. You can copy that binary to a server with no Go installation and run it. The binary is static. It links everything at build time.

C# compiles to Intermediate Language, which runs on the .NET runtime. The runtime uses Just-In-Time compilation to translate IL to machine code at startup. You need the runtime installed on the target machine, or you publish a self-contained deployment that bundles the runtime. The self-contained deployment is larger and takes longer to build.

Startup time differs significantly. Go binaries start in milliseconds. The runtime initializes quickly, and there is no JIT compilation. C# applications start slower due to JIT compilation and framework initialization. Modern .NET has improved startup time with Ahead-of-Time compilation, but Go still wins for cold starts.

Memory usage also differs. Go's garbage collector is tuned for low latency. It uses concurrent marking and sweeping to minimize pause times. C#'s garbage collector is tuned for throughput. It uses generational collection and compaction. Modern .NET has improved low-latency modes, but Go's GC is simpler and more predictable for network services.

Realistic example: Error handling and cancellation

Real backend code deals with errors, timeouts, and resource cleanup. Go handles errors explicitly. C# handles errors with exceptions. Go uses context.Context for cancellation. C# uses CancellationToken.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"
)

// FetchData retrieves data from an upstream service with a timeout.
func FetchData(ctx context.Context) ([]byte, error) {
    // Context is always the first parameter. It carries deadlines and cancellation.
    // Functions that take a context should respect cancellation and deadlines.
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
    if err != nil {
        return nil, fmt.Errorf("creating request: %w", err)
    }

    // Client.Do executes the request.
    // The context is attached to the request, so cancellation propagates.
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("request failed: %w", err)
    }
    // Defer closes the body to prevent resource leaks.
    // Defer runs when the function returns, regardless of the return path.
    defer resp.Body.Close()

    // ReadAll reads the response body.
    // It returns an error if the read fails or the context is cancelled.
    return io.ReadAll(resp.Body)
}

func main() {
    // Create a context with a 5-second deadline.
    // The deadline ensures the request doesn't hang forever.
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    // Cancel releases resources associated with the context.
    // Always call cancel to avoid leaking the context.
    defer cancel()

    data, err := FetchData(ctx)
    if err != nil {
        // Handle the error. In a real app, you might log and return an HTTP 500.
        // The error chain preserves the original error for debugging.
        fmt.Println("Error:", err)
        return
    }

    fmt.Println("Got data:", string(data))
}
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

public class DataService
{
    private readonly HttpClient _httpClient;

    // Constructor injection for dependencies.
    // The framework manages the lifetime of the HttpClient.
    public DataService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    // GetData fetches data with cancellation support.
    public async Task<string> GetDataAsync(CancellationToken cancellationToken)
    {
        try
        {
            // SendAsync performs the HTTP request.
            // The cancellation token propagates to the underlying socket.
            var response = await _httpClient.GetAsync("https://api.example.com/data", cancellationToken);
            response.EnsureSuccessStatusCode();

            // ReadAsStringAsync reads the response content.
            // It respects the cancellation token during the read.
            return await response.Content.ReadAsStringAsync(cancellationToken);
        }
        catch (HttpRequestException ex)
        {
            // Log the exception and rethrow or handle appropriately.
            // Exceptions unwind the stack, which can be costly in hot paths.
            Console.WriteLine($"Request failed: {ex.Message}");
            throw;
        }
    }
}

Go uses if err != nil to check errors. The pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally swallow an error. C# uses try/catch. The code is cleaner, but errors can be hidden deep in the call stack. You need to read the function signature or documentation to know what exceptions might be thrown.

Go wraps errors with fmt.Errorf("...: %w", err). The %w verb preserves the error chain. You can unwrap the error later to check for specific types. C# uses exception types. You catch specific types to handle different errors. The type system distinguishes errors.

Context is plumbing in Go. Run it through every long-lived call site. The context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. C# uses CancellationToken. It's usually the last parameter. The pattern is similar, but Go's context also carries values, which can be abused. Use context for cancellation, not for passing data.

Pitfalls and compiler behavior

Go has fewer runtime surprises, but the compiler can be strict. If you forget to use a variable, the compiler rejects the program with declared and not used. This prevents dead code but can be annoying during rapid prototyping. If you try to pass a string where an int is expected, you get cannot use x (type string) as type int in argument. The type system is strong and static.

If you forget to capture the loop variable correctly, the compiler rejects the program with loop variable i captured by func literal in Go 1.22 and later. This error prevents a common bug where all goroutines share the same loop variable. In older versions, the bug was silent and caused hard-to-find race conditions.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. The worst goroutine bug is the one that never logs. The program runs fine, but memory grows until it crashes. Use context to cancel goroutines. Use select with a done channel to exit loops.

C# has null reference exceptions. Go has nil pointers, but you can't dereference nil without a panic. C# has nullable reference types now, but it's opt-in. Go forces you to check nil explicitly. The compiler doesn't track nullability. You write the checks.

Don't pass a *string. Strings are already cheap to pass by value. Passing a pointer adds indirection and hurts cache performance. Only pass pointers when you need to modify the value or when the value is large.

Public names start with a capital letter. Private names start lowercase. No keywords like public or private. The convention is simple and consistent. Interfaces are accepted, structs are returned. "Accept interfaces, return structs" is the most common Go style mantra. It keeps dependencies loose and implementations flexible.

Decision matrix

Use Go when you need a single binary deployment with zero runtime dependencies. Use Go when your workload involves high concurrency with many short-lived connections. Use Go when your team values simple tooling and consistent code formatting over advanced language features. Use Go when you want fast compilation times and predictable performance. Use Go when you are building CLI tools, microservices, or infrastructure software.

Use C# when you are building a complex domain model that benefits from advanced type system features like generics constraints or pattern matching. Use C# when you need deep IDE support, refactoring tools, and a mature ecosystem for enterprise integrations. Use C# when your project requires cross-platform desktop applications or game development with Unity. Use C# when your team prefers exception-based error handling and dependency injection frameworks. Use C# when you need a rich standard library with extensive APIs for data access, cryptography, and UI.

Go reduces cognitive load by saying no. C# expands capability by saying yes. Pick the constraint that helps your team.

Where to go next