Notes

Why Memory Safety Is a Language Property, Not a Compiler Feature

Why Rust and Go are memory safe and type safe while C is not, and why you cannot simply fix this with a better C compiler.

deep dive 21 min read

A question that comes up often: if Rust and Go can produce memory-safe binaries, why can’t we just upgrade the C compiler to do the same thing? Isn’t it the compiler’s job to produce safe code?

The short answer is no. Memory safety, type safety, and similar guarantees are properties of the language, not the compiler. The compiler enforces them, but only because the language gives it enough information to do so. C doesn’t provide that information, and bolting it on after the fact would create a different language.

See It for Yourself: The Same Bug in Three Languages

The best way to understand the difference is to write the same flawed program in C, Rust, and Go and watch what happens. We’ll try three classic memory safety bugs: a buffer overflow, a use-after-free, and a null pointer dereference.

You can follow along on any Linux or macOS system with gcc, rustc, and go installed.

Example 1: Buffer Overflow (Out-of-Bounds Access)

We allocate an array of 5 integers and then read the 10th element — well past the end.

C (overflow.c):

#include <stdio.h>

int main(void) {
    int data[5] = {10, 20, 30, 40, 50};
    printf("data[10] = %d\n", data[10]);
    return 0;
}

Build and run:

gcc -o overflow overflow.c
./overflow

This compiles without errors. When you run it, you get… something. Maybe data[10] = 0, maybe data[10] = 32767, maybe a different value every time. You’re reading whatever happens to be on the stack 20 bytes past your array. No crash, no warning, just silent corruption. This is the class of bug behind Heartbleed, which leaked private keys from OpenSSL by reading past a heap buffer boundary. The basic stack buffer overflow tutorial demonstrates how an attacker turns this kind of memory corruption into code execution.

Now compile with AddressSanitizer to see what a runtime check looks like:

gcc -fsanitize=address -o overflow overflow.c
./overflow

Now you get a crash with a detailed report: stack-buffer-overflow on address 0x.... But notice: you had to opt into this, it only catches the bug if this code path executes, and the instrumented binary runs 2-3x slower. This is a tool, not a guarantee.

Rust (overflow.rs):

fn main() {
    let data = [10, 20, 30, 40, 50];
    println!("data[10] = {}", data[10]);
}

Build:

rustc overflow.rs
error: this operation will panic at runtime
 --> overflow.rs:3:36
  |
3 |     println!("data[10] = {}", data[10]);
  |                                ^^^^^^^ index out of bounds:
  |                                the length is 5 but the index is 10

The compiler catches this at compile time because it can see the array length (5) and the index (10) are both constants. It doesn’t just warn you — it refuses to produce a binary.

What about when the index isn’t a constant? Rust inserts a runtime bounds check:

fn main() {
    let data = [10, 20, 30, 40, 50];
    let i: usize = std::env::args().len() + 10; // runtime value
    println!("data[{i}] = {}", data[i]);
}
rustc overflow.rs && ./overflow
thread 'main' panicked at /tmp/overflow.rs:4:32:
index out of bounds: the len is 5 but the index is 11

An immediate, deterministic panic with exact details — not silent corruption.

Go (overflow.go):

package main

import "fmt"

func main() {
    data := [5]int{10, 20, 30, 40, 50}
    fmt.Println("data[10] =", data[10])
}

Build:

go build overflow.go
./overflow.go:7:36: invalid argument: index 10 out of bounds [0:5]

Like Rust, Go catches the constant index at compile time. For runtime indices, Go panics with a bounds check:

package main

import (
    "fmt"
    "os"
)

func main() {
    data := [5]int{10, 20, 30, 40, 50}
    i := len(os.Args) + 10 // runtime value
    fmt.Println("data[i] =", data[i])
}
go build -o overflow overflow.go && ./overflow
panic: runtime error: index out of range [11] with length 5

goroutine 1 [running]:
main.main()
    /tmp/overflow.go:9 +0x65

Same outcome as Rust — a clean panic, not memory corruption. The difference: Rust and Go both rely on bounds checks for safe indexing, but Rust’s ownership and type system push more safety reasoning into compile time, while Go leans more heavily on runtime enforcement.

Example 2: Use-After-Free

We allocate memory, free it, then try to use it.

C (uaf.c):

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

int main(void) {
    char *name = malloc(16);
    strcpy(name, "hello");
    free(name);
    printf("name = %s\n", name); // use after free
    return 0;
}
gcc -o uaf uaf.c && ./uaf

This compiles and likely prints name = hello — the freed memory hasn’t been reused yet, so the old data is still there. In a larger program, this becomes a security vulnerability: an attacker who controls the next allocation can place controlled data into the freed slot, and your code reads it thinking it’s still name. This is one of the most exploited vulnerability classes in the wild.

Rust (uaf.rs):

fn main() {
    let name = String::from("hello");
    drop(name); // explicit free
    println!("name = {name}");
}
rustc uaf.rs
error[E0382]: borrow of moved value: `name`
 --> uaf.rs:4:27
  |
2 |     let name = String::from("hello");
  |         ---- move occurs because `name` has type `String`
3 |     drop(name);
  |          ---- value moved here
4 |     println!("name = {name}");
  |                       ^^^^ value borrowed here after move

The compiler won’t let you use name after it’s been dropped. This isn’t a warning — it’s a hard error. The ownership system means drop(name) moves the value into the drop function, and the variable is no longer valid. There’s no binary to run. The bug is caught before the program executes.

Go:

package main

import "fmt"

func main() {
    name := "hello"
    // Go has no manual free -- the GC handles it.
    // You literally cannot write use-after-free in safe Go.
    fmt.Println("name =", name)
}

Go sidesteps this entirely in safe code. There’s no free() function. The garbage collector determines when memory is unreachable and reclaims it automatically. You can’t free something and then use it because you can’t free anything manually. That removes the classic manual use-after-free bug class from ordinary Go code.

Example 3: Null Pointer Dereference

C (null.c):

#include <stdio.h>

int main(void) {
    int *ptr = NULL;
    printf("value = %d\n", *ptr);
    return 0;
}
gcc -o null null.c && ./null
Segmentation fault (core dumped)

You get a segfault — the operating system catches this one because address 0 is unmapped. But the crash gives you no context about why the pointer was null, which variable it was, or which code path led here. In more complex programs, null pointer dereferences don’t always hit address 0 (struct member access offsets the null, e.g., ((struct foo *)NULL)->field in practice often accesses address 0 + field offset), making them harder to diagnose and potentially exploitable.

Rust:

fn main() {
    // Safe Rust references are non-null. For optional values,
    // you model absence explicitly with Option<T>.
    let ptr: Option<i32> = None;

    // This would compile, but panic at runtime on None:
    // println!("value = {}", ptr.unwrap());

    match ptr {
        Some(val) => println!("value = {val}"),
        None => println!("no value"),
    }
}

In safe Rust, references are non-null. Instead, Option<T> makes the absence of a value explicit in the type system. The compiler forces you to acknowledge the None case somewhere. You can call .unwrap() to panic on None, but that’s an explicit choice, not an accidental dereference. Raw pointers can still be null in unsafe Rust, but nullability is no longer the default model for ordinary references.

Go (null.go):

package main

import "fmt"

func main() {
    var ptr *int = nil
    fmt.Println("value =", *ptr)
}
go build -o null null.go && ./null
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]

goroutine 1 [running]:
main.main()
    /tmp/null.go:5 +0x...

Go has nil pointers, so null dereferences are possible. But the runtime catches them immediately with a clear panic and stack trace, rather than producing a bare segfault. You know the exact file, line, and goroutine. The program terminates deterministically rather than exhibiting undefined behavior.

What These Examples Show

The same logical error — accessing memory you shouldn’t — has three completely different outcomes:

BugCRustGo
Buffer overflowSilent corruptionCompile error or runtime panicCompile error or runtime panic
Use-after-freeSilent, exploitableCompile error (ownership)Not expressible via manual free in safe Go
Null dereferenceSegfault (undefined behavior)Explicit in the type system (Option<T>); panic only if chosenRuntime panic with stack trace

C lets every one of these bugs through silently or with a bare crash. Rust either rejects them at compile time or turns them into explicit, deterministic failures in safe code. Go prevents some by design and catches the rest at runtime with clear diagnostics. The difference isn’t the quality of the compiler — it’s the information the language provides.

What the Language Defines

A programming language is a set of rules that define what programs mean. These rules determine:

  • What operations are legal
  • What information the programmer must provide
  • What invariants the compiler is allowed to assume

When Rust says every reference has a lifetime and every value has exactly one owner, that’s a language rule. It constrains what you’re allowed to write. In return, the compiler can prove strong properties about safe Rust code at compile time, including ruling out dangling references and data races that would violate those rules.

When Go says pointers exist but pointer arithmetic doesn’t, that’s a language rule. It means the runtime can track every allocation and the garbage collector can safely reclaim memory without the programmer ever freeing it manually.

C says: here’s a pointer, it’s an integer-sized value, do whatever you want with it. Cast it, add to it, subtract from it, dereference it after freeing the underlying memory. The language permits all of these operations. The compiler has no basis to reject them.

Why You Can’t Just Fix the Compiler

A compiler translates a language. It doesn’t get to redefine it.

If you modified GCC or Clang to reject programs that use pointer arithmetic, you’d break virtually every C program ever written. The Linux kernel does pointer arithmetic. malloc implementations do pointer arithmetic. String processing in C is built on pointer arithmetic. It’s not a misuse of the language — it’s a core feature of the language.

If you added borrow checking to a C compiler, what would it check against? C has no concept of ownership or lifetimes. There’s nothing in the language to annotate, track, or verify. The compiler would need the programmer to provide new information — lifetime annotations, ownership markers, borrowing rules — at which point you aren’t compiling C anymore. You’re compiling a new language that looks like C.

This isn’t hypothetical. Cyclone, Checked C, and CCured all tried this approach: take C’s syntax, add safety annotations, build a smarter compiler. Each one became its own language. None achieved meaningful adoption, because the moment you require programmers to annotate their code differently, you’ve broken compatibility with every existing C codebase.

The Information Gap

The core issue is information. Safe languages force you to express your intent precisely enough that the compiler can verify correctness. Unsafe languages give you maximum freedom and minimum obligations.

Consider a function signature:

void process(int *data, int len);

The C compiler knows almost nothing useful here. Is data allowed to be null? How many elements does it point to? Is len the count in bytes or elements? Does the caller or callee own the memory? Can this be called from multiple threads? The language doesn’t require answers to any of these questions.

Now in Rust:

fn process(data: &[i32]) {
    // ...
}

The compiler knows: data is a borrowed reference (non-null, non-owning), it points to a contiguous slice of i32 values, the length is tracked as part of the slice, and the borrow checker guarantees no one else is mutating this data while we’re reading it. All of this comes from the type system and language rules, not from a clever compiler optimization.

Compiler Warnings Aren’t Safety

C compilers do try to help. GCC and Clang have -Wall, -Wextra, address sanitizers, undefined behavior sanitizers, and static analysis passes. These catch real bugs. But they’re fundamentally limited:

Warnings are heuristic, not sound. A warning pass can flag some use-after-free patterns, but it cannot catch all of them without false positives that would make the compiler unusable. Rust’s borrow checker is sound for the rules of safe Rust it enforces: if safe code compiles, those checked aliasing and lifetime properties hold. C’s warnings are best-effort.

Sanitizers are runtime tools. AddressSanitizer catches buffer overflows, but only on code paths that actually execute during testing. They slow programs down 2-3x. They find bugs; they don’t prevent them. Compare this to Rust, where many guarantees come from compile-time checking, and the remaining failures in safe code are typically explicit panics rather than silent memory corruption.

Static analysis doesn’t scale to the language. Tools like Coverity and the Clang static analyzer are impressive, but they’re fighting the language. Every cast, every void *, every union is a place where the analyzer loses track of types and must give up or guess.

Compile-Time vs. Runtime Checks: Two Paths to Safety

There are fundamentally two ways a language can enforce safety: prove it before the program runs (compile-time), or check it while the program runs (runtime). Rust and Go represent these two approaches, and understanding the difference explains a lot about when each language shines.

Compile-Time Enforcement: Rust’s Approach

Rust’s borrow checker is a static analysis built into the compiler that runs before any code is generated. It’s not a lint or a warning — it’s a hard gate. If the borrow checker can’t prove your program is safe, it doesn’t compile. Period.

This works through a system of rules the compiler enforces on every reference:

  1. Ownership: every value has exactly one owner. When the owner goes out of scope, the value is dropped (freed). No garbage collector needed.
  2. Borrowing: you can have either one mutable reference OR any number of immutable references to a value, but never both at the same time.
  3. Lifetimes: every reference has a lifetime that the compiler tracks. A reference can never outlive the data it points to.
fn main() {
    let mut data = vec![1, 2, 3];
    let reference = &data[0];  // immutable borrow
    data.push(4);              // ERROR: can't mutate while borrowed
    println!("{}", reference);
}

This won’t compile. The push might reallocate the vector’s backing memory, which would invalidate reference. The borrow checker sees that reference is still in use and refuses to let you mutate data. In C, this would compile fine and produce a dangling pointer that might work 99% of the time and segfault in production under load.

The key property here is soundness for safe Rust: the borrow checker does not accept code that violates the ownership and borrowing rules it models (setting aside unsafe code and compiler bugs). This is fundamentally different from a heuristic warning that catches most problems. A sound check means that for the property being checked, accepted safe code satisfies it.

Much of the cost is paid at compile time. Rust has no garbage collector, and many safety properties are enforced before the binary exists. But safe Rust still uses some runtime checks, such as slice bounds checks, and those checks panic instead of producing undefined behavior. In practice, Rust often delivers C-like performance while making memory-safety failures explicit.

Runtime Enforcement: Go’s Approach

Go takes the opposite strategy. Rather than proving safety at compile time, it builds safety checks into the running program:

Garbage collection prevents use-after-free and double-free. The Go runtime tracks every allocation and periodically scans memory to find objects that are no longer reachable. You never call free() — the runtime handles it. This removes the manual deallocation bugs that are common in C, though Go programs can still leak memory by retaining references longer than intended.

func createUser(name string) *User {
    u := &User{Name: name}  // allocated on heap
    return u                 // perfectly safe -- GC tracks it
}
// In C, returning a pointer to a local would be a bug.
// In Go, the runtime ensures the allocation lives as long as needed.

Bounds checking happens on every array and slice access. When you write data[i], the compiled binary includes a check that i is within range. If it’s not, the program panics immediately with a clear stack trace rather than silently corrupting adjacent memory.

data := []int{1, 2, 3}
fmt.Println(data[5]) // panic: runtime error: index out of range [5]
                     // with length 3

In C, data[5] would read whatever happens to be at that memory address — maybe garbage, maybe a security-sensitive value, maybe memory that another thread is writing to. The buffer over-read is one of the most common vulnerability classes in C code (it’s what caused Heartbleed).

No pointer arithmetic means you can’t compute arbitrary memory addresses. In C, ptr + offset lets you reach any memory location. Go doesn’t allow this (outside of the unsafe package), which means every pointer the runtime sees is either valid or nil — never a wild pointer into unmapped memory.

The race detector is an optional runtime tool (go run -race) that instruments memory accesses to detect concurrent reads and writes to the same variable without synchronization. Unlike Rust’s compile-time prevention, this only catches races on code paths that actually execute during testing. But combined with Go’s channel-based concurrency model, it’s effective in practice.

The Tradeoff Between the Two Approaches

Neither approach is strictly better. They make different tradeoffs:

Compile-time (Rust)Runtime (Go)
When bugs are caughtBefore deploymentDuring execution
CompletenessSound for safe Rust’s checked rulesDepends on code coverage
Runtime costLow, workload-dependent runtime checksGC pauses, bounds checks (~1-5%)
Memory overheadNoneGC metadata, larger heap
Developer experienceSteeper learning curveSimpler, faster iteration
Escape hatchunsafe blocksunsafe package
Concurrency safetySafe Rust prevents data races at compile timeData races detected at runtime

Rust’s compile-time approach means you pay most of the cost once during development. There is no garbage collector in production, and many memory-safety properties have already been checked before deployment. That matters for latency-sensitive systems, embedded targets, and anywhere garbage collection pauses are unacceptable.

Go’s runtime approach means you pay a small continuous cost (GC pauses, bounds check instructions) but get a much faster development cycle and a gentler learning curve. This matters for services where developer productivity and time-to-deploy are more important than extracting every last microsecond.

Why C Gets Neither

C has no compile-time ownership model for the compiler to check, and no runtime to insert safety checks. The language specification explicitly defines many dangerous operations as undefined behavior rather than requiring either a compile error or a runtime check. When you dereference a freed pointer in C, the standard doesn’t say “the compiler must reject this” or “the program must abort.” It says the behavior is undefined — anything can happen, and the compiler is under no obligation to help you.

This is why C compilers can’t simply “add bounds checking.” Even if you inserted a range check before every array access, C allows you to obtain a pointer to an array element and then pass that pointer elsewhere — the bounds information is lost. The pointer is just an address. Without language-level tracking of where a pointer came from and what it’s allowed to access (which Rust has via slices and lifetimes, and Go has via its runtime), there’s no way to check bounds consistently.

This is the fundamental gap. Safety requires information — either rich enough types for the compiler to reason about statically, or a managed runtime that tracks allocations dynamically. C provides neither, by design.

Why Rust’s Compiler Errors Are So Helpful

One of the most surprising things developers experience when coming to Rust from C is how useful the error messages are. This isn’t an accident — it’s a direct consequence of the language giving the compiler enough information to understand what you meant.

The Compiler Knows What You Meant

When the Rust compiler rejects your code, it doesn’t just say “error.” It tells you why your code is wrong, what rule you violated, and often how to fix it:

error[E0502]: cannot borrow `data` as mutable because it is also
              borrowed as immutable
  --> src/main.rs:4:5
   |
3  |     let r = &data[0];
   |              ---- immutable borrow occurs here
4  |     data.push(4);
   |     ^^^^^^^^^^^^ mutable borrow occurs here
5  |     println!("{r}");
   |               - immutable borrow later used here
   |
help: consider cloning the value if you need both accesses

This error message shows you the exact lines involved, explains the conflict (immutable borrow vs. mutable borrow), marks where each borrow starts and where it’s used, and suggests a fix. It does this because the language’s ownership rules give the compiler a complete picture of what’s happening with every reference.

Compare this to what C gives you when you make an equivalent mistake. In most cases: nothing. The program compiles, runs, and maybe works fine for months. Then one day the vector resizes, the pointer dangles, and you get a segfault in production with a stack trace that points to the wrong line because the optimizer moved things around.

When you do get C compiler warnings, they’re often vague:

warning: 'ptr' may be used uninitialized in this function

“May be.” The compiler isn’t sure. It can see a code path where this might happen, but the language doesn’t give it the tools to know definitively. So it hedges.

Errors as Teaching Tools

Rust’s error messages are detailed enough that they function as tutorials. Each error has an error code (like E0502 above) that links to an extended explanation with examples. When you’re learning Rust, the compiler is actively teaching you about ownership, borrowing, and lifetimes — not just blocking you.

A lifetime error that initially feels like the compiler is being needlessly strict usually reveals a genuine design flaw:

error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:32
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----      ^ expected named lifetime
  |                                    parameter
  |
  = help: this function's return type contains a borrowed value,
    but the signature does not say whether it is borrowed from
    `x` or `y`

The compiler is asking: “if I return a reference, which input does it come from? I need to know so I can guarantee the caller doesn’t use this reference after the source data is freed.” This isn’t bureaucracy — it’s exposing an ambiguity that, in C, would be a latent bug.

Why C Can’t Do This

C compilers can’t produce errors like this because the language doesn’t encode the information needed to generate them. To tell you “you can’t mutate this while it’s borrowed,” the compiler needs to know what borrowing is. To tell you “this reference might outlive the data,” it needs to track lifetimes.

C’s type system encodes almost none of this:

  • int * — is this owned? Borrowed? Nullable? Pointing to one element or many? The type doesn’t say.
  • char * — is this a string? A byte buffer? An output parameter? The type doesn’t say.
  • void * — this could be literally anything. The compiler has abandoned all type information.

Without this information, the best a C compiler can do is pattern-match on common mistakes. That catches some bugs, but it’s a fundamentally different thing from Rust’s compiler, which has a complete model of your program’s memory access patterns and can reason about them exhaustively.

This is why experienced Rust developers say “if it compiles, it usually works.” The compiler has verified so many properties of your program that the remaining bugs tend to be logic errors, not memory corruption, not data races, not undefined behavior. The error messages might feel like a strict teacher, but they’re catching bugs that would take days to debug in C — if you found them at all.

”Safe C” Would Not Be C

Imagine you did build a C compiler that:

  • Rejected pointer arithmetic
  • Required lifetime annotations
  • Enforced bounds checking on all array access
  • Prevented type punning through unions and casts
  • Required explicit null checks before dereference

This compiler would reject the vast majority of existing C code. It couldn’t compile the Linux kernel, glibc, SQLite, OpenSSL, or essentially any real-world C project. Programs written for it would look fundamentally different from C programs — they’d be programs in a new language that merely shares C’s keywords.

And that’s exactly what Rust is. Rust started with many C-like concepts (manual memory management, no garbage collector, systems-level control) and redesigned the language rules so the compiler has enough information to guarantee safety. The syntax is different, but more importantly, the contract between programmer and compiler is different.

The transition mirrors the shift from loose powder and paper-cartridge muzzle loading toward factory-made self-contained metallic cartridges in the 1860s and 1870s. The old approach worked, but correctness depended on the individual soldier performing each loading step correctly under pressure. The metallic cartridge did not make soldiers more careful — it moved powder, primer, and projectile alignment into a manufactured unit. Rust makes the same move: not trusting the programmer to get manual memory management right every time, but redesigning the contract so the dangerous manual step no longer exists.

When to Reach for Which Language

Understanding why these languages have different safety properties also helps you choose between them.

Choose C when:

  • You’re writing firmware, bootloaders, or code that runs before an operating system exists, and the existing toolchain, SDK, or vendor ecosystem is built around C.
  • You’re contributing to an existing C codebase (the Linux kernel, CPython, PostgreSQL internals). Rewriting isn’t always practical, and consistency within a project matters.
  • You need to target obscure or resource-constrained platforms where Rust and Go toolchains, libraries, or operational expertise are not realistic options.
  • You’re writing a thin layer that bridges hardware and software — device drivers, interrupt handlers, or memory-mapped I/O — and project constraints make a low-level C interface the path of least resistance.

Accept the tradeoff: you’re taking on full responsibility for memory management and must invest heavily in testing, fuzzing, and code review to compensate.

Choose Rust when:

  • You need C-level performance and control with stronger safety guarantees. Rust’s abstractions are designed to compile down efficiently, while much of the safety work happens before deployment rather than in a garbage-collected runtime.
  • You’re building security-sensitive systems: cryptographic libraries, parsers for untrusted input, network protocol implementations, or anything that processes attacker-controlled data.
  • You need fearless concurrency. The ownership system prevents data races at compile time, which makes Rust excellent for parallel and concurrent workloads.
  • You’re writing a new systems project — a database engine, an operating system component, a browser engine, a CLI tool — and don’t have legacy C code forcing your hand.
  • Long-running services where a memory leak or use-after-free would be catastrophic (you can’t just restart a satellite).

Accept the tradeoff: steeper learning curve, longer compile times, and fighting the borrow checker until ownership becomes intuitive. The compiler is strict because it’s checking properties that C leaves to discipline and testing. For a practical example of choosing Rust for a systems project, see Why Rust Is the Right Language for eBPF.

Choose Go when:

  • You’re building networked services, APIs, microservices, or infrastructure tooling. Go was designed for exactly this: highly concurrent server software.
  • Developer velocity matters more than squeezing out every CPU cycle. Go compiles fast, reads easily, and has a shallow learning curve.
  • Your team is large or has mixed experience levels. Go’s simplicity and strong opinions (formatting, error handling, package structure) reduce the surface area for disagreement.
  • You need robust concurrency but don’t want to think about memory ownership. Goroutines and channels give you safe concurrency with minimal ceremony.
  • You’re writing DevOps and platform tooling (Docker, Kubernetes, Terraform, and Prometheus are all Go).

Accept the tradeoff: garbage collection pauses (small but nonzero), higher memory usage than C or Rust, and less control over memory layout. Go optimizes for programmer productivity over maximum performance.

The general heuristic: if you’re close to hardware or need deterministic performance, choose Rust over C for new projects. If you’re building services and infrastructure, Go is hard to beat. Reach for C only when the ecosystem or constraints demand it — and when they do, pair it with every sanitizer and static analysis tool available.

The Takeaway

Memory safety isn’t a switch you flip in a compiler. It’s a design decision baked into a language’s type system, ownership model, and rules about what programmers are and aren’t allowed to express. Languages like Rust and Go made different tradeoffs than C — they require more from the programmer (or restrict what the programmer can do) in exchange for guarantees that the compiler or runtime can enforce.

C made its tradeoffs in the 1970s: maximum flexibility, minimal overhead, trust the programmer. Those tradeoffs were reasonable for the hardware and use cases of the time. But you can’t get safety guarantees out of a language that was designed to not require them. The information simply isn’t there for any compiler to work with.

The right framing isn’t “why can’t we make C safe?” It’s “Rust and Go are what you get when you design a language where safety is possible.”