Alright, let’s talk about CGo. You’ve probably heard the horror stories. It’s the part of Go that feels like it was designed by a committee who had a very, very tense meeting with the C standards body and then decided to duct-tape the two languages together. And you know what? It kind of was. But sometimes, you just have to talk to a C library. Maybe it’s a rock-solid database client, a hardware SDK, or that one numerical library that’s been optimized within an inch of its life. When you need to, CGo is your bridge. It’s a powerful tool, but it’s also a footgun of spectacular proportions if you don’t respect it.

The first thing to understand is that importing C is magic. No, really. You don’t import a package; you import the concept of C itself with a special pseudo-package. The comment immediately above the import "C" statement is not a comment in the traditional sense—it’s CGo preamble code. The compiler literally parses it and uses it as header information. Forget this, and you’ll have a very bad time.

/*
#include <stdio.h>
#include <stdlib.h>

// Your C functions, typedefs, and #defines go here.
// This isn't just for show; this code is plopped into the C side of the generated code.
*/
import "C"

import (
    "unsafe" // You'll become very familiar with this package.
)

The Basic Mechanics: Your First C Function Call

Let’s start with the “Hello, World” of CGo: calling a simple C function. Here’s how you wrap a call to C’s puts (or printf—but let’s not get ahead of ourselves).

package main

/*
#include <stdio.h>
*/
import "C"

func main() {
    // We're calling C.puts, not Go's fmt.Println.
    // The C string is converted from a Go string automatically.
    C.puts(C.CString("Hello from the C world!"))
}

Run that. It works! But I just committed a cardinal sin. Did you spot it? We allocated a C string using C.CString and then promptly leaked it like a sieve. C.CString uses malloc to allocate memory on the C heap. The Go garbage collector, brilliant as it is, doesn’t know about that memory. It will never free it. This is Pitfall #1, and it’s the easiest way to introduce memory leaks into your application.

Doing It Right: Memory Management is Your Job

The correct way is to immediately defer the freeing of that memory. You must manage C-allocated memory explicitly. There’s no way around it.

package main

/*
#include <stdio.h>
#include <stdlib.h> // This is needed for free()
*/
import "C"
import "unsafe"

func main() {
    cs := C.CString("This one won't leak!")
    defer C.free(unsafe.Pointer(cs)) // Defer the call to C's free()
    C.puts(cs)
}

Notice the unsafe.Pointer. That’s your signal that you’re leaving the safety of Go’s world. You’re telling the compiler, “I, the programmer, take full responsibility for this.” It’s the equivalent of a giant “HERE BE DRAGONS” sign on a map. Use it with extreme prejudice.

Passing Data Back and Forth: It’s All C Types Now

When you cross the boundary, you’re dealing with C types, not Go types. C.int, C.double, C.char, etc. You must convert between Go and C types explicitly. This is often verbose and always tedious, but it’s crucial for clarity.

Let’s say you have a C function you want to call:

// In your preamble:
int add(int a, int b) {
    return a + b;
}

Your Go code to call it would look like this:

package main

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

func main() {
    a := 10
    b := 20
    // Convert Go ints to C ints, call the function, convert the result back.
    result := int(C.add(C.int(a), C.int(b)))
    fmt.Printf("%d + %d = %d\n", a, b, result)
}

The Performance Elephant in the Room

Here’s the part the manuals often gloss over: CGo calls are expensive. Monumentally so, by Go’s standards. A call from Go into C isn’t a simple function call. It’s a full-blown context switch between goroutine stacks and C system stacks. The runtime has to lock threads and do a bunch of accounting. We’re talking hundreds of nanoseconds of overhead per call.

This means you should never, ever use CGo for fine-grained calls inside a tight loop. The design pattern is to create a coarse-grained C API where you pass a buffer of data to C, let C do all the heavy lifting on that buffer, and then return a result. One CGo call doing a lot of work is fine; a million CGo calls doing a tiny amount of work each will destroy your performance.

The Build Process: It Gets Weird

When you use go build on a package with CGo, it doesn’t just invoke the Go compiler. It creates a temporary directory, writes out all the C code from your preamble, compiles it with a C compiler (like gcc or clang), and then links it with the Go code. This means:

  1. You need a C toolchain installed. This is a massive portability hurdle.
  2. You inherit all of C’s build complexities. #cgo directives in your preamble let you specify compiler and linker flags. Need to link a specific library? You’ll do it here.
/*
#cgo LDFLAGS: -lm -lmy_special_library

#include <math.h>
#include <my_special_library.h>
*/
import "C"

This is both a superpower and a maintenance burden. You’re now writing build instructions inside comments in your Go code. It’s as bizarre as it sounds.

So, should you use CGo? The answer is: only if you absolutely must. It’s a fantastic escape hatch for when you have no other choice, but it comes at a real cost in performance, complexity, and portability. Use it to wrap existing C libraries into coarse-grained APIs, not to write new logic. And for the love of all that is holy, always remember to free.