Pangram verdict · v3.3
We believe that this document is primarily human-written, with a small amount of AI-assisted content detected
AI likelihood · overall
HumanArticle text · 1,796 words · 5 segments analyzed
We’re in the process of significantly improving memory safety in C#. The unsafe keyword is being redesigned to inform callers that they have obligations that must be discharged to maintain safety, documented via a new safety comment style. The keyword will expand from marking pointers to any code that interacts with memory in ways the compiler cannot validate as safe. The compiler will enforce that the unsafe keyword is used to encapsulate unsafe operations. The result is that safety contracts and assumptions become visible and reviewable instead of implied by convention. We plan to release the new model and syntax (nominally a C# 16 feature) as a preview in .NET 11 and as a production release in .NET 12. It will initially be opt-in and may become the default in a later release. We will update templates to enable the new model just like we have done with nullable reference types. The early compiler implementation has landed in main and is taking shape. C# 1.0 introduced the unsafe keyword as the way to establish an unsafe context on types, methods, and interior method blocks, letting developers choose the most convenient scope. An unsafe context grants access to pointer features. A method marked unsafe can use those features in its signature and implementation while unmarked methods cannot. We also exposed a set of unsafe types like System.Runtime.CompilerServices.Unsafe and System.Runtime.InteropServices.Marshal that required careful usage as a convention. The unsafe keyword has since been reused and remixed in Rust and Swift, where those language teams gave it stricter, propagation-oriented semantics. C# 16 follows the same path, applies unsafe uniformly (including on Unsafe and Marshal members) in the .NET runtime libraries, and most closely resembles the Rust implementation. The result: unsafe stops marking a kind of syntax and starts marking a kind of contract; one the compiler can’t verify, that a skilled developer has to read and uphold. C# already blocks unsafe code by default. Most developers won’t notice any change when they enable the new model because they don’t enable or use unsafe APIs. The default block will cover a much larger surface area when the C# 16 safety model is enabled. The new model establishes strong guard rails that are visible, reviewable, and enforced by the compiler. It is also an important tool to enforce engineering and supply chain standards.
Memory safety has been a rising priority across industry and government for several years, and AI-assisted code generation adds a new dimension as software production scales faster than human review. Safety An earlier post discusses the structural safety mechanisms in .NET: safety is enforced by a combination of the language and the runtime … Variables either reference live objects, are null, or are out of scope. Memory is auto-initialized by default such that new objects do not use uninitialized memory. Bounds checking ensures that accessing an element with an invalid index will not allow reading undefined memory — often caused by off-by-one errors — but instead will result in a IndexOutOfRangeException. Source: What is .NET, and why should you choose it? C# comes with strong safety enforcement for regular safe code. The new model enables developers and agents to accurately mark safety boundaries in unsafe code. There are two reasons to write unsafe code: interoperating with native code, and in some cases for performance. Go, Rust, and Swift also include an unsafe dialect for these cases. The language typically cannot help you write unsafe code; its role is to make clear where unsafe code is used and how it transitions back to safe code. Programming safety may be easier to understand if we consider another domain. Road designers improve safety by painting solid yellow or white lines that prohibit crossing into oncoming traffic. Drivers understand and abide by this convention. High-speed highways use barriers to provide safety via structural separation that continues to function in the absence of sober compliance. The highway example shows us that higher speeds come with higher stakes. Programming has its own kind of accidents, with memory. Every application has potential access to gigabytes of virtual memory. Writing to or reading from arbitrary memory results in arbitrary behavior (Undefined Behavior, or UB, is the industry term) and is the cause of most security bugs. Accessing arbitrary memory isn’t possible in safe code, but is an ever-present possibility in unsafe code. The model in a nutshell .NET programs are expected to uphold one core invariant: every memory access targets live memory: memory that is allocated, initialized, and available at the time of access. Safe code upholds this by construction: compiler rules and runtime checks combine to make a stray access impossible. Unsafe code is any operation that can violate the invariant, typically by reading or writing memory that isn’t live, or by leaving memory in a state where a later access will fail.
Unsafe code can read or write arbitrary memory accessed via interop, by NativeMemory, or hand-managed by the developer. The invariant must hold all the same. The compiler can’t detect UB there, so the burden of validation shifts to the developer. The solution to this risk is a layered set of mechanics that intentionally and transparently push unsafety through the call graph, each layer enabling the next: Inner unsafe { } block: every unsafe operation (calling an unsafe member, dereferencing a pointer, and other unsafe actions) must appear inside an inner unsafe { } block. This is the base mechanic. Unsafe operations are syntactically marked, scoped, and reviewable. Propagation: adding unsafe to the enclosing method’s signature republishes the inner block’s obligations to its own callers, unless discharged. This carves the call graph into safe methods, unsafe methods, and the boundary methods between them. Developers can chain propagation through any number of intermediates before someone decides to stop. Safety documentation: every unsafe member should carry a /// <safety> block: the formal contract between callee and caller. Authoring it is a strongly encouraged best practice, and analyzers can flag its absence. Suppression at the boundary: a method that contains an inner unsafe block but does not mark its own signature unsafe is the boundary between unsafe and safe code. It discharges the callee’s documented obligations, through runtime guards on inputs, static reasoning, or documented invariants from upstream APIs (e.g., malloc guaranteeing the returned pointer is valid for at least size bytes). Correct discharge is what makes safe callers actually safe. You have to step through each layer to get the value. Do half the work and you get much less than half the value. Step through each layer correctly and you have a connected line of reasoning through a call graph that others can review and potentially improve. Writing unsafe code is a special skill that requires a strong understanding of this invariant and of many pitfalls.
The new model makes unsafe code easier to reason about and review, not easier to write — it forces a formal, visible structure. The keywords and compiler enforcement aren’t the safety; they’re the scaffolding that gets developers to articulate and honor it. C# 1.0 grouped a category of “pointer features” under unsafe: declaring and dereferencing pointer types, taking the address of variables, stackalloc to a pointer, sizeof on arbitrary types, and other capabilities added over the years, including the suppression of certain compiler errors. The new model is more selective. Changes relative to C# 1.0 rules include: The unsafe type modifier produces an error. Unsafe scope moves down to individual methods, properties, and fields, where its contract is in view and more minimally specified. Delegates also cannot be unsafe because they are type-shaped. unsafe is not allowed on static constructors or finalizers. Their invocations don’t have a call site pattern that can be wrapped in an unsafe { } block, so the signature marker has nothing to propagate. The new() generic constraint matches only a safe parameterless constructor; a type whose parameterless constructor is unsafe can’t satisfy new(). A new safe keyword lets a developer attest that a declaration is sound where the compiler requires the choice to be explicit. Today the only such place is extern declarations, which must be marked safe or unsafe, including LibraryImport partial method declarations. unsafe on a member no longer establishes an unsafe context. Interior unsafe blocks are now required at unsafe call sites. Pointer types in signatures no longer propagate unsafety. Only pointer dereferences are unsafe, so a byte* parameter doesn’t propagate unsafety to its callers on its own. For new code, avoid IntPtr for pointers; prefer typed pointers like byte*, or void* for truly opaque pointers. For existing IntPtr-based APIs, consider adding pointer-typed overloads and hiding or soft-obsoleting the IntPtr versions. For opaque handles, prefer SafeHandle. nint and IntPtr are indistinguishable in metadata, so when a parameter is genuinely a native-sized integer, document that explicitly. Adoption is via a new opt-in project-level property. See § Project-level opt-in for the details. The model in practice Unsafe code significantly raises the stakes and is always unbounded in some dimension.
The best unsafe APIs are designed to make the unboundedness as narrow as possible: pushing what they can into the signature, discharging what they can in the body, and leaving the caller with a small, well-defined residue to handle themselves. Encoding.GetString(byte*, int) is a good example. public unsafe string GetString(byte* bytes, int byteCount) { ArgumentNullException.ThrowIfNull(bytes); ArgumentOutOfRangeException.ThrowIfNegative(byteCount); return string.CreateStringFromEncoding(bytes, byteCount, this); } The method clearly communicates what the API expects: the byte* parameter advertises a raw, unmanaged buffer, and the paired byteCount says exactly how many bytes the API will read. The body discharges what it can: a null pointer or negative length is rejected with an exception. The guards remove a subset of cases where string.CreateStringFromEncoding will silently read arbitrary memory. GetString returns a new string, removing any aliasing or lifetime concerns with the buffer. The caller holds a single, narrow obligation: byteCount bytes starting at bytes must be readable memory. Passing a length larger than the buffer is undefined behavior: the decoder may run into unreadable memory and crash, or it may read whatever happens to live past the end and return a string built from arbitrary foreign bytes. In the existing model, the byte* in the signature is what prevents this API from being called from safe code. Under the new model, a pointer in a signature no longer implies unsafety on its own; GetString will be explicitly annotated unsafe so it stays uncallable from safe code. “Better unsafe” isn’t defined by more or less dangerous, but by more or less descriptive of unsafety; sharp knives make the finest cuts, and dull ones tear. Marshal.ReadByte is a more cautionary case. public static unsafe byte ReadByte(IntPtr ptr, int ofs) { try { byte* addr = (byte*)ptr + ofs; return *addr; } catch (NullReferenceException) { throw new AccessViolationException(); } } Callers of Marshal.