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.

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:
cat > d.c <<'EOF'
int *make(void) {
int x = 42;
return &x;
}
EOF
cc -Wall -c d.c 2>&1 | head -1d.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:
package main
import "fmt"
func make42() *int {
x := 42
return &x
}
func main() {
p := make42()
q := make42()
*p++
fmt.Println(*p, *q)
}43 42The 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.
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)
}{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.
#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;
}strlen=6 copy=hépackage main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo"
fmt.Println(len(s), utf8.RuneCountInString(s), s[:3])
}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:
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'ERROR: AddressSanitizer: stack-buffer-overflow on address ADDR at pc ADDR bp ADDR sp ADDRWithout 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:
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)
}runtime error: index out of range [6] with length 3Without 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.
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint8_t u = 255;
u = u + 1;
printf("%u\n", u);
return 0;
}0package main
import "fmt"
func main() {
var u uint8 = 255
u++
var i int8 = 127
i++
fmt.Println(u, i)
}0 -128Both 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:
#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;
}package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("missing.txt")
if err != nil {
fmt.Println("open failed:", err)
return
}
f.Close()
}open failed: open missing.txt: no such file or directoryThe 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.
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())
}
}main.Rect 12.00
main.Circle 3.14An 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:
#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;
}4000package 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)
}4000Both 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:
package main
/*
static int add(int a, int b) { return a + b; }
*/
import "C"
import "fmt"
func main() {
fmt.Println(C.add(2, 3))
}5It 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.


