You have written unsafe code before. You slapped the keyword on a method, did some pointer arithmetic, and moved on. The compiler checked that AllowUnsafeBlocks was set in your project file, and that was the extent of the safety model. The keyword meant "I need pointers" and nothing more.
That is about to change significantly. On 21 May 2026, Richard Lander published a detailed proposal on the .NET Blog outlining a fundamental redesign of how unsafe works in C#. The keyword is being transformed from a syntax gate into a formal safety contract system — one that propagates obligations through the call graph, requires documentation, and makes boundary methods explicitly visible. If you have ever looked at Rust's unsafe model and wished C# had something similar, this is Microsoft's answer.
The changes are targeted for C# 16 as a preview in .NET 11 and a production release in .NET 12. They are opt-in initially, but the direction is clear: this will eventually become the default.
The problem with unsafe today
The current unsafe keyword has a fundamental design flaw: it does not propagate. When you mark a method as unsafe, any caller can invoke it without the compiler raising so much as a warning.
unsafe void WriteByte(byte* ptr, int offset, byte value)
{
ptr[offset] = value;
}
void Caller()
{
// This compiles without any unsafe context.
// The caller has no idea it is taking on safety obligations.
WriteByte(somePointer, 42, 0xFF);
}
The caller above is silently accepting responsibility for ensuring somePointer is valid, that offset is in bounds, and that the memory will not be freed during the call. None of these obligations are visible in the code. The compiler does not enforce them, does not warn about them, and does not require the caller to acknowledge them.
This gets worse with APIs like System.Runtime.CompilerServices.Unsafe and System.Runtime.InteropServices.MemoryMarshal, which perform fundamentally unsafe operations — reinterpret casts, pointer-to-reference conversions, write barrier bypasses — yet are callable from perfectly safe-looking code. The Unsafe.As<TFrom, TTo> method can corrupt the GC heap if misused, but nothing in the language prevents a developer from calling it in a method with no unsafe modifier at all.
The new model: unsafe as a contract
The redesign reframes unsafe as a caller-facing contract. When a method signature carries the unsafe modifier, it means: "Calling this method imposes obligations that the compiler cannot verify. You must understand and discharge those obligations."
Three interlocking mechanisms enforce this:
1. Inner unsafe blocks at every call site
Every call to an unsafe method must be wrapped in an unsafe { } block, even if the calling method is itself marked unsafe. This makes each unsafe operation syntactically visible and scoped.
/// <safety>
/// The caller must ensure ptr points to at least (offset + 1) readable bytes.
/// </safety>
public static unsafe byte ReadByte(IntPtr ptr, int offset)
{
unsafe
{
// SAFETY: relies on caller obligation documented above.
return ((byte*)ptr)[offset];
}
}
The inner unsafe { } block is not redundant — it marks the precise point where unverifiable operations occur. In a method with multiple statements, only the lines inside unsafe { } blocks carry risk. Everything outside them is compiler-verified safe code.
2. Propagation through the call graph
When a method calls an unsafe member without discharging the obligations, it must propagate the unsafe modifier to its own signature:
/// <safety>
/// The caller must ensure ptr points to at least 4 readable bytes.
/// </safety>
unsafe int ReadInt32(IntPtr ptr)
{
unsafe
{
return ReadByte(ptr, 0) | (ReadByte(ptr, 1) << 8)
| (ReadByte(ptr, 2) << 16) | (ReadByte(ptr, 3) << 24);
}
}
This creates a chain of obligation that flows upward through the call graph until it reaches a boundary method.
3. Suppression at boundary methods
A method that validates its inputs and guards against invalid states can suppress the propagation — it does not need unsafe on its own signature:
public int ReadInt32(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 4)
throw new ArgumentException("Buffer too small.", nameof(buffer));
unsafe
{
fixed (byte* ptr = buffer)
{
// SAFETY: bounds check above guarantees at least 4 readable bytes.
return ReadByte((IntPtr)ptr, 0) | (ReadByte((IntPtr)ptr, 1) << 8)
| (ReadByte((IntPtr)ptr, 2) << 16) | (ReadByte((IntPtr)ptr, 3) << 24);
}
}
}
The ReadInt32(ReadOnlySpan<byte>) method is a safe boundary. It accepts only managed types, validates its input, and discharges all safety obligations before entering the unsafe block. Callers of this method have no obligations to fulfil — the contract is fully satisfied internally.
Safety documentation: <safety> and // SAFETY:
The model introduces two documentation conventions, both borrowed directly from Rust's ecosystem.
The /// <safety> XML tag
Every unsafe member must carry a /// <safety> documentation block that describes the caller's obligations. An analyser flags missing blocks.
/// <safety>
/// The sum of ptr and ofs must address a byte the caller is permitted to read.
/// The memory region must not be freed or relocated during the call.
/// </safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs)
{
unsafe
{
return ((byte*)ptr)[ofs];
}
}
This is not a suggestion — it is an analysable contract. Tools can verify that every unsafe member has a <safety> block, and code reviewers can assess whether the documented obligations are actually discharged at call sites.
The // SAFETY: comment
Inside method bodies, a // SAFETY: comment explains why a particular unsafe block is sound. It is a note to reviewers, not a compiler-checked artefact:
public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentOutOfRangeException.ThrowIfNegative(count);
ArgumentOutOfRangeException.ThrowIfGreaterThan(sourceIndex + count, Length);
ArgumentOutOfRangeException.ThrowIfGreaterThan(destinationIndex + count, destination.Length);
unsafe
{
// SAFETY: all bounds validated above; source pointer valid for Length bytes
// per class invariant; destination pinned by fixed.
fixed (char* dst = destination)
{
Buffer.MemoryCopy(_ptr + sourceIndex, dst + destinationIndex,
(destination.Length - destinationIndex) * sizeof(char),
count * sizeof(char));
}
}
}
The // SAFETY: comment is the developer's proof that they have thought about why the operation is valid. If you cannot write a convincing // SAFETY: comment, you probably do not understand the invariants well enough to be writing the code.
Breaking changes in the new model
The redesign includes several breaking changes that will apply when a project opts in:
unsafe moves from types to members. You can no longer mark an entire class or struct as unsafe. The modifier must go on individual methods, properties, and fields. This forces granular reasoning about which members actually carry obligations.
Static constructors and finalisers cannot be unsafe. These are called by the runtime without developer control over the call site, so there is no meaningful way to discharge obligations.
The safe keyword for extern declarations. Every extern method (including those using LibraryImport) must carry either unsafe or the new safe keyword. This forces developers to make an explicit attestation about P/Invoke declarations:
// Must explicitly choose: is this safe or unsafe to call?
[LibraryImport("kernel32.dll")]
internal static safe partial int GetCurrentProcessId();
[LibraryImport("kernel32.dll")]
/// <safety>
/// The handle must be a valid, open process handle with PROCESS_QUERY_INFORMATION access.
/// </safety>
internal static unsafe partial bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
new() constraint matches only safe constructors. If a type's parameterless constructor is marked unsafe, it cannot satisfy a where T : new() constraint. This prevents generic code from unknowingly constructing types with safety obligations.
The four-layer enforcement model
The redesign creates a layered system that partitions the entire call graph into three regions:
| Region | Description |
|---|---|
| Safe code | No unsafe keyword anywhere. The compiler guarantees memory safety. |
| Unsafe code | Methods with unsafe on their signature. Obligations propagate to callers. |
| Boundary code | Methods that call unsafe APIs internally but discharge all obligations through guards. Their signature is safe. |
Boundary methods are the most interesting category. They are where developers do the actual safety reasoning — validating bounds, checking null, ensuring lifetimes — and they are the natural audit targets for security reviews. The new model makes them grep-friendly: search for unsafe { inside methods that lack unsafe on their signature, and you have found every boundary in the codebase.
How C#, Rust, and Swift compare
The three languages have converged on remarkably similar safety models, though with different flavours:
C# and Rust use explicit propagation. An unsafe signature means the method has obligations; a non-unsafe method that calls an unsafe one must either propagate or suppress. Pointer types alone do not determine unsafety — only the unsafe modifier does.
Swift uses implicit propagation. Any @unsafe type appearing in a method's signature automatically makes that method @unsafe. This is simpler but requires broader @safe opt-outs and arguably demands more domain knowledge to navigate.
The C# team explicitly chose the Rust model over Swift's, noting that explicit rules require less domain knowledge and produce clearer audit trails. The // SAFETY: comment convention is taken directly from Rust's // SAFETY: convention, and the /// <safety> tag is the XML-doc equivalent of Rust's # Safety section in doc comments.
Project-level configuration
Two independent project properties control the behaviour:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net12.0</TargetFramework>
<!-- Opt into the new safety model (name TBD) -->
<UnsafeSafetyModel>true</UnsafeSafetyModel>
<!-- Existing property: allows unsafe keyword usage -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
The configuration matrix:
| Safety model | AllowUnsafeBlocks | Behaviour |
|---|---|---|
| On | Off | Safest: participates in the model, but no unsafe code allowed in this project |
| On | On | Full model: propagation, documentation requirements, inner blocks |
| Off | Any | Legacy C# 1.0 rules apply |
The property name is not finalised, but the intent is to mirror the rollout of nullable reference types: opt-in per project, gradually becoming the default in templates.
Cross-assembly behaviour
The model handles mixed assemblies through metadata:
- Opted-in caller, opted-in callee: full enforcement. The compiler reads
unsafemarkers from metadata and enforces propagation. - Opted-in caller, legacy callee: compatibility mode. Pointer-type signatures are treated as
unsafe, but non-pointer unsafe APIs (likeUnsafe.As) go undetected. - Legacy caller, opted-in callee: the new
unsafemarkers are invisible to the legacy compiler. Methods that areunsafeonly under the new model (no pointer types) appear safe to legacy callers.
This is the same graduated adoption strategy that nullable reference types used, and it carries the same limitation: the safety guarantees are only as strong as the weakest link in the dependency chain.
Migration tooling
A dotnet format fixer is planned that handles mechanical rewrites:
- Wrapping
unsafecall sites inunsafe { }blocks - Moving
unsafemodifiers from types to individual members - Adding stub
/// <safety>blocks (which you must then fill in manually)
The fixer cannot infer safety obligations or write meaningful // SAFETY: comments. The intellectual work of understanding why each unsafe block is sound remains firmly human.
Common pitfalls
Treating // SAFETY: comments as optional. They are not enforced by the compiler, but they are the single most important artefact of the new model. An unsafe { } block without a // SAFETY: comment is a code smell — it means nobody documented why the operation is valid.
Over-propagating unsafe. If every method in a call chain carries unsafe on its signature, the model collapses into the same state as today — nobody knows where the actual obligations are. The goal is to push unsafe down to the lowest level and suppress at well-defined boundaries.
Assuming the model catches everything. The cross-assembly compatibility mode means that legacy libraries can still expose unsafe operations through safe-looking APIs. The model is only fully effective when all dependencies opt in.
Confusing unsafe fields with unsafe methods. A field like unsafe byte* _ptr means the field itself carries invariants (for example, it must be null or point to a valid allocation of a known size). Accessing the field requires an unsafe block, which forces the accessor to reason about those invariants.
Ignoring the safe keyword on extern declarations. Every LibraryImport and DllImport method will need either safe or unsafe. Marking everything safe to silence the compiler defeats the purpose — review each P/Invoke and decide whether its misuse can corrupt memory.
Summary
- The
unsafekeyword is being redesigned from a syntax gate into a formal safety contract system, planned for preview in .NET 11 and production in .NET 12. unsafeon a method signature now means the caller has obligations that must be documented and discharged.- Inner
unsafe { }blocks are required at every unsafe operation, even insideunsafemethods. /// <safety>XML tags document caller obligations;// SAFETY:comments explain why specific blocks are sound.- Boundary methods — safe signatures wrapping unsafe internals with proper guards — are the key audit points.
- The model closely follows Rust's approach: explicit propagation, explicit suppression, and explicit documentation at every level.
- Migration tooling will handle mechanical rewrites, but writing meaningful safety documentation remains a manual task.
- Opt-in via a project property, rolling out like nullable reference types — gradually, then everywhere.