Skip to content
gohacks.dev
Basics

Go for C Developers Who Want Fewer Footguns in Systems Code

Go for C developers: what carries over (structs, pointers, speed) and what changes (garbage collection, bounds checks, errors), with C and Go pairs.

13 min read
Summarize in AI
Flat illustration of an editor window full of code.

If you write C, most of Go will feel familiar within an afternoon, and that is deliberate. Go was designed at Google by people who had spent decades with C and Unix, and it shows: braces, for loops, structs, pointers, a main function, a compiler that produces one native executable. What changes is what the language will not let you do. The things that cause the 2 a.m. incidents in C, such as a dangling pointer, an off-by-one past the end of a buffer or an uninitialized variable, are either impossible or turned into a clean runtime error. This guide walks through the differences in the order a C programmer meets them, with a C program and a Go program side by side wherever the behavior is easy to show. The C was compiled with Apple clang 21 and the Go run with 1.25.6, and every output below is what the program printed. It belongs to the Go basics path.

Tested with Go 1.25.6 and Apple clang 21, macOS on Apple Silicon (arm64)

What carries over unchanged

A C programmer gets a head start on more of Go than they expect. Types have a fixed size and a clear representation, so a struct is a block of fields laid out in memory, and an array of structs is contiguous. Passing a struct to a function copies it, exactly as in C, and passing a pointer shares it. A pointer is an address, &x takes one and *p follows one. Integers wrap around in two's complement. The language has no classes, no inheritance and no exceptions, and a program is functions operating on data. Compilation is fast, the result is a static executable, and you can inspect it with the tools you already know: objdump, nm, strace, perf and gdb all work.

That is why the first hour is mostly vocabulary. int main(void) becomes func main(), #include becomes import, typedef struct becomes type T struct, and the type goes after the name in a declaration. The rest of this article is about the parts that are not vocabulary.

Memory: no free, and no dangling pointers

The largest change is that you do not manage the heap. There is a garbage collector, there is no free, and a value stays alive for as long as something can reach it. The consequence a C programmer feels first is that the classic stack-escape bug does not exist. This C function compiles with a warning:

Shell
cat > d.c <<'EOF'
int *make(void) {
    int x = 42;
    return &x;
}
EOF
cc -Wall -c d.c 2>&1 | head -1
Text
d.c:3:13: warning: address of stack memory associated with local variable 'x' returned [-Wreturn-stack-address]

The warning is useful, and in a real program the function would compile and return a pointer to a stack slot that the next call overwrites. In Go the equivalent is normal, idiomatic code:

main.goGo
package main

import "fmt"

func make42() *int {
	x := 42
	return &x
}

func main() {
	p := make42()
	q := make42()
	*p++
	fmt.Println(*p, *q)
}
Text
43 42

The two pointers are independent, and the incremented one does not disturb the other. The compiler runs escape analysis, notices that x outlives the function, and allocates it on the heap instead of the stack, so the address stays valid. You can ask it what it decided with go build -gcflags=-m, and stack and heap in Go explains how to read that output. The trade-off is that the garbage collector now runs alongside your program, which costs some CPU and a small amount of latency, and it means you do not get to say when memory is returned. How the garbage collector works covers what it costs and how to tune it. For services and tools, this is an excellent bargain. For an interrupt handler or a flight controller, it is not, and C or Rust is the right answer.

Allocation itself is simpler. make and new replace malloc, composite literals such as &Point{1, 2} replace the malloc plus initialization pair, and there is no calloc question because every allocation is already zeroed. That last property removes a whole class of bugs.

Zero values replace uninitialized memory

In C, int n; inside a function holds whatever was on the stack, and reading it is undefined behavior. In Go every variable has a defined starting value: 0, "", false, nil, or a struct of zeros. A struct you declare without initializing is ready to use, and well-designed types make that zero value meaningful, which is the idea behind the useful zero value.

main.goGo
package main

import "fmt"

type Counter struct {
	hits int
	last string
	seen map[string]bool
}

func main() {
	var c Counter
	fmt.Printf("%+v %v\n", c, c.seen == nil)
	c.hits++
	fmt.Printf("%+v\n", c)
}
Text
{hits:0 last: seen:map[]} true
{hits:1 last: seen:map[]}

Nothing here reads garbage, because there is no garbage to read. Note the one trap: a nil map can be read but not written, which is a panic and not undefined behavior, and the zero values article lists which types need make before use.

Strings carry their length

A C string is a pointer to bytes ending in a zero byte, and everything from strlen to buffer overflows follows from that. A Go string is a pointer and a length, immutable, and it may contain any bytes including zeros. Taking its length is constant time, slicing it shares the memory, and concatenation allocates a new string, so there is no buffer for you to size.

C
#include <stdio.h>
#include <string.h>

int main(void) {
    const char *s = "héllo";
    char buf[4];
    strncpy(buf, s, sizeof buf - 1);
    buf[3] = '\0';
    printf("strlen=%zu copy=%s\n", strlen(s), buf);
    return 0;
}
Text
strlen=6 copy=hé
main.goGo
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "héllo"
	fmt.Println(len(s), utf8.RuneCountInString(s), s[:3])
}
Text
6 5 hé

Both agree that "héllo" is six bytes, because the source is UTF-8 and the accented letter takes two. C needed a destination buffer, a bound and a manual terminator to take the first three bytes, and forgetting the last line would have been a bug. Go sliced it in one expression. What Go adds is a rune type for a code point and range over a string that decodes UTF-8, which C leaves to a library. The mechanics are in strings and bytes.

Arrays and slices: bounds are checked

C arrays decay to pointers and carry no length, so a[i] with a bad i is undefined behavior, which usually means a crash on a good day and a silent corruption on a bad one. Go arrays are values with a length, and the slice type, a pointer plus a length plus a capacity, is what you pass around. Every index is checked. Compiled with AddressSanitizer, the C program reading one slot too far gives:

Shell
cat > o.c <<'EOF'
#include <stdio.h>

int main(int argc, char **argv) {
    int a[3] = {1, 2, 3};
    int i = argc + 5;
    printf("%d\n", a[i]);
    return 0;
}
EOF
cc -fsanitize=address -g o.c -o o && ./o 2>&1 | grep -m1 ERROR | sed -E 's/==[0-9]+==//; s/0x[0-9a-f]+/ADDR/g'
Text
ERROR: AddressSanitizer: stack-buffer-overflow on address ADDR at pc ADDR bp ADDR sp ADDR

Without the sanitizer there is no check at all, and the program reads whatever happens to sit past the array. In Go the same mistake is a defined, catchable event:

main.goGo
package main

import (
	"fmt"
	"os"
)

func get(a []int, i int) (v int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("%v", r)
		}
	}()
	return a[i], nil
}

func main() {
	a := []int{1, 2, 3}
	_, err := get(a, len(os.Args)+5)
	fmt.Println(err)
}
Text
runtime error: index out of range [6] with length 3

Without the recover, the program would stop with panic: runtime error: index out of range [6] with length 3 and a stack trace, as the index panic article shows. The check costs a comparison per access, which the compiler removes when it can prove the index is in range. For a loop over len(a) it usually can, and hot code that profiling shows is bound by checks can use a hint or a single up-front slice expression. Slices are the biggest new idea for a C programmer because several of them can share one backing array, and slices explained and the append gotcha cover what that means for aliasing.

Pointers without arithmetic

Go has pointers and does not have pointer arithmetic. You cannot write p + 1 or cast an integer to a pointer, and walking an array is done with an index or a range. The unsafe package restores arithmetic for the rare case that needs it, and the name is the warning. A pointer to a struct field is allowed, and field access through a pointer needs no arrow: p.x works whether p is a value or a pointer. Pointers can be nil, and dereferencing one is a panic rather than a segmentation fault, so you get a stack trace with a line number instead of a core dump. Pointers in Go and pointer or value receivers show when to use each.

There is no void *. The nearest thing is the empty interface, any, which carries a type with the value and has to be type-asserted back, so the cast is checked and not trusted (type assertions). Generics, added in Go 1.18, cover most of the cases where you would have reached for void * and a size argument.

Integers: sized, wrapping and explicit

Go's integer types are int8 to int64, uint8 to uint64, and the platform-sized int, uint and uintptr. Unlike C, there is no promotion to int in arithmetic and no implicit conversion between types: mixing an int and an int64 is a compile error, and you write the conversion. Signed overflow is defined as wrapping, so the optimizer never deletes a check on the strength of undefined behavior, which is a real difference from C compilers.

C
#include <stdio.h>
#include <stdint.h>

int main(void) {
    uint8_t u = 255;
    u = u + 1;
    printf("%u\n", u);
    return 0;
}
Text
0
main.goGo
package main

import "fmt"

func main() {
	var u uint8 = 255
	u++
	var i int8 = 127
	i++
	fmt.Println(u, i)
}
Text
0 -128

Both unsigned results wrap to zero. The signed case, 127 + 1 in an int8, is undefined behavior in C when it happens in an int, and defined as -128 in Go. Integer overflow in Go shows more edge cases, and type conversion covers why the explicit casts are a feature. Constants are the other pleasant surprise: const values are untyped and arbitrary precision until they are used, which replaces many #define macros (untyped constants), and iota replaces a numbered enum (iota).

Errors: return values, but typed

C reports failure with a return value and errno. Go keeps the return-value convention and replaces the global with a second return value of type error, so there is no state to forget to check or to clobber. The classic example, opening a file that does not exist:

C
#include <stdio.h>
#include <string.h>
#include <errno.h>

int main(void) {
    FILE *f = fopen("missing.txt", "r");
    if (f == NULL) {
        printf("fopen failed: %s\n", strerror(errno));
        return 1;
    }
    fclose(f);
    return 0;
}
main.goGo
package main

import (
	"fmt"
	"os"
)

func main() {
	f, err := os.Open("missing.txt")
	if err != nil {
		fmt.Println("open failed:", err)
		return
	}
	f.Close()
}
Text
open failed: open missing.txt: no such file or directory

The C program exits with status 1 after printing the system's message, and errno has to be read before another call changes it. The Go error carries the operation and the file name, and can be wrapped with context as it travels up the stack. The compiler refuses to compile a program that declares err and never uses it. It does not force you to look at it, because f, _ := os.Open(...) is legal, and a linter such as errcheck catches the unchecked case (errcheck). Cleanup that C does with goto fail is defer, which runs on every exit path (defer order). A fuller treatment of the pattern is in error handling in Go.

Interfaces instead of function-pointer tables

A C programmer building polymorphism writes a struct of function pointers, a vtable, and fills it in by hand. Go makes the pattern a language feature: an interface is a set of methods, a type satisfies it by having those methods, and no declaration ties the two together.

main.goGo
package main

import (
	"fmt"
	"math"
)

type Shape interface {
	Area() float64
}

type Rect struct{ W, H float64 }
type Circle struct{ R float64 }

func (r Rect) Area() float64   { return r.W * r.H }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }

func main() {
	for _, s := range []Shape{Rect{3, 4}, Circle{1}} {
		fmt.Printf("%T %.2f\n", s, s.Area())
	}
}
Text
main.Rect 12.00
main.Circle 3.14

An interface value is two words, a pointer to type information and a pointer to the data, so the dispatch costs about what an indirect call costs in C, and nothing is looked up by name at run time. The Go structure is the one the C programmer would have built, with the bookkeeping generated. There are no headers to keep in sync either: a package is a directory, an exported name starts with a capital letter, and the compiler reads the dependencies it needs (packages and exported names). Interfaces explained goes deeper, including the trap where a nil pointer inside an interface is not a nil interface.

Threads become goroutines

POSIX threads work and take about thirty lines for a worker pool, which is why most C programs avoid them until they must. Here is a shared counter incremented from four threads under a mutex, then the same thing in Go:

C
#include <pthread.h>
#include <stdio.h>

static long counter;
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;

static void *work(void *arg) {
    for (int i = 0; i < 1000; i++) {
        pthread_mutex_lock(&mu);
        counter++;
        pthread_mutex_unlock(&mu);
    }
    return NULL;
}

int main(void) {
    pthread_t t[4];
    for (int i = 0; i < 4; i++) pthread_create(&t[i], NULL, work, NULL);
    for (int i = 0; i < 4; i++) pthread_join(t[i], NULL);
    printf("%ld\n", counter);
    return 0;
}
Text
4000
main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		mu      sync.Mutex
		counter int
		wg      sync.WaitGroup
	)
	for range 4 {
		wg.Go(func() {
			for range 1000 {
				mu.Lock()
				counter++
				mu.Unlock()
			}
		})
	}
	wg.Wait()
	fmt.Println(counter)
}
Text
4000

Both print 4000. The Go version has no thread handles, no function signature built around void *, and no join loop. A goroutine starts with a stack of a few kilobytes that grows on demand, so you can run a hundred thousand of them where a hundred thousand threads would exhaust the machine. Goroutines explained and the scheduler describe the model, wg.Go is the Go 1.25 addition covered in the WaitGroup.Go article, and the mutex article covers the lock itself. The mutex is shared state, the same as C. Go's preferred alternative is to pass data over a channel instead of sharing it, and the built-in race detector (go test -race, the race detector) finds the data races that a C program needs ThreadSanitizer for, with the same overhead and a simpler command line.

Calling C from Go, and the build

Sometimes the library you need exists only in C. cgo lets a Go file include C declarations in a comment above import "C" and call them directly:

main.goGo
package main

/*
static int add(int a, int b) { return a + b; }
*/
import "C"

import "fmt"

func main() {
	fmt.Println(C.add(2, 3))
}
Text
5

It works, and it carries costs worth knowing before you adopt it. A call across the boundary is far slower than a Go call, the binary now depends on a C toolchain at build time and often on libc at run time, cross-compilation stops being a one-line GOOS=linux go build, and a blocking C call holds an operating system thread (cgo and blocked threads). The official introduction is C? Go? Cgo! and the reference is the cgo command documentation. Prefer a pure Go library when one exists. The build is otherwise shorter than a Makefile: go build reads imports, resolves modules from go.mod and compiles in dependency order, with no header search paths, and #ifdef becomes build constraints in file names or //go:build lines (build tags).

What C still does better

Go is not a drop-in replacement, and it should not pretend to be. Choose C, or Rust, when you need to control layout and timing exactly: operating system kernels, bootloaders, device drivers, firmware on a part with 32 KB of memory, and hard real-time loops where a collector pause of even a fraction of a millisecond is out of budget. Go also adds a runtime, about a megabyte of it in the smallest binary (what ships inside every binary), so size-critical targets lean on TinyGo, a separate compiler for microcontrollers. And some low-level tricks are deliberately awkward: bit fields, unions, flexible array members, computed goto and inline assembly are either missing or sit behind unsafe.

What you get in exchange is a language where the common mistakes are loud and early. That is a good deal for network services, command line tools, infrastructure and anything that has to be maintained by people who did not write it. Read the Tour of Go for the syntax and Effective Go for the idioms, keep the Go specification open as you would a C standard, and let the first service you write teach you the rest.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.