Every .NET developer has been there. You're writing a unit test and the value you need to assert is buried inside a private field. Or you're building a library that needs to read an internal property from a framework type. The traditional escape hatch — System.Reflection — works, but it's slow, allocation-heavy, and invisible to the trimmer and Native AOT compiler. Since .NET 8, there's been a better option hiding in plain sight: UnsafeAccessorAttribute.
This attribute lets you call private methods, read private fields, and invoke private constructors with literally zero runtime overhead. The JIT treats your accessor exactly like a direct member access. No MethodInfo caching, no Invoke boxing, no Reflection.Emit gymnastics. And because the binding happens at compile time, it works perfectly with trimming and ahead-of-time compilation — something traditional reflection fundamentally cannot guarantee.
The reflection tax
Before looking at the solution, it's worth quantifying the problem. Here's a class with a private method that you need to call from outside:
public class OrderProcessor
{
private decimal CalculateDiscount(decimal subtotal)
{
return subtotal > 500m ? subtotal * 0.1m : 0m;
}
}
The classic reflection approach:
var processor = new OrderProcessor();
var method = typeof(OrderProcessor)
.GetMethod("CalculateDiscount", BindingFlags.NonPublic | BindingFlags.Instance);
var result = (decimal)method!.Invoke(processor, [250m])!;
This works, but there are three costs hiding behind those four lines. First, GetMethod walks the type's metadata every time you call it — even with caching, the initial lookup isn't free. Second, Invoke boxes value-type arguments and return values, creating allocations on every call. Third, the trimmer has no way to statically prove which members your reflection code will touch, so it either keeps everything or breaks your app.
Benchmarks tell the story clearly:
| Method | Mean |
|---|---|
| Reflection | ~36.0 ns |
| Reflection (cached) | ~21.3 ns |
| UnsafeAccessor | ~0.004 ns |
| Direct access | ~0.003 ns |
That's not a percentage improvement — it's four orders of magnitude. UnsafeAccessor is indistinguishable from calling the method directly.
How it works
The API surface is deliberately minimal. You declare an extern static method, apply [UnsafeAccessor] to it, and the runtime fills in the implementation at JIT time. There's no code generation step, no IL weaving, and no post-build tool required.
The attribute takes an UnsafeAccessorKind to specify what you're accessing:
Constructor— invoke a private constructor to create a new instanceMethod— call a private instance methodStaticMethod— call a private static methodField— read or write a private instance fieldStaticField— read or write a private static field
The first parameter of your extern static method identifies the owning type. For instance members, this argument acts as the this pointer.
Accessing private fields
Field accessors return a ref to the field, giving you both read and write access through a single declaration:
public class OrderProcessorTests
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_discountThreshold")]
static extern ref decimal GetDiscountThreshold(OrderProcessor processor);
[Fact]
public void Discount_applied_above_threshold()
{
var processor = new OrderProcessor();
ref decimal threshold = ref GetDiscountThreshold(processor);
// Read the private field
Assert.Equal(500m, threshold);
// Write to it for a test scenario
threshold = 100m;
}
}
The Name property must exactly match the field name as declared in IL — including any compiler-generated prefixes. For auto-properties backed by <PropertyName>k__BackingField, you'd use that mangled name. For regular fields, use the field name directly.
// TIP
Use nameof where possible to catch renames at compile time. For fields that follow the _camelCase convention, you'll need a string literal since nameof can't see private members from outside the type.
Calling private methods
Method accessors follow the same pattern. The first parameter is the target instance, and remaining parameters match the private method's signature:
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "CalculateDiscount")]
static extern decimal CallCalculateDiscount(OrderProcessor processor, decimal subtotal);
[Fact]
public void Discount_is_ten_percent_above_threshold()
{
var processor = new OrderProcessor();
var discount = CallCalculateDiscount(processor, 600m);
Assert.Equal(60m, discount);
}
For static methods, use UnsafeAccessorKind.StaticMethod. The first parameter still identifies the owning type, but its value is ignored — you can pass null:
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "GetDefaultCurrency")]
static extern string CallGetDefaultCurrency(OrderProcessor? _);
Invoking private constructors
There are two ways to call a private constructor. The first creates a new instance directly:
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
static extern Order CreateOrder(int id, DateTime placed);
[Fact]
public void Can_construct_order_with_private_ctor()
{
var order = CreateOrder(42, new DateTime(2026, 4, 26));
Assert.Equal(42, order.Id);
}
The return type of the extern method is the type being constructed, and the parameters match the constructor's parameters. You don't set the Name property — the runtime matches on the parameter signature alone.
The second approach calls the constructor as an instance method on an already-allocated object, which is useful when you need to separate allocation from initialisation:
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = ".ctor")]
static extern void CallOrderCtor(Order order, int id, DateTime placed);
Accessing properties
There's no UnsafeAccessorKind.Property because properties in .NET are just methods with a naming convention. Access the getter or setter directly:
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_CreditLimit")]
static extern decimal GetCreditLimit(Customer customer);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_CreditLimit")]
static extern void SetCreditLimit(Customer customer, decimal value);
This mirrors how the CLR actually represents properties — there's no magic, you're just calling the backing methods.
Working with structs
When the target type is a struct, instance accessors require the first parameter to be passed by ref. This ensures the runtime has a pointer to the actual struct rather than a copy:
public struct Coordinate
{
private double _latitude;
private double _longitude;
}
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_latitude")]
static extern ref double GetLatitude(ref Coordinate coord);
Forgetting the ref on the first parameter is one of the most common mistakes. The runtime won't find a match and will throw MissingFieldException — the error message doesn't hint that you need ref.
// WARNING
For struct instance members, the first parameter must be ref. Without it, the runtime can't locate the member and throws MissingFieldException with no indication that the missing ref is the actual problem.
Generic support (.NET 9+)
.NET 8 shipped UnsafeAccessorAttribute without generic support — a significant limitation that was addressed in .NET 9. To access members on generic types, wrap your accessor in a generic class whose type parameters mirror those of the target:
public class Repository<T> where T : class
{
private readonly List<T> _items = [];
private void AddInternal(T item) => _items.Add(item);
}
static class RepositoryAccessors<T> where T : class
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
public static extern ref List<T> GetItems(Repository<T> repo);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "AddInternal")]
public static extern void CallAddInternal(Repository<T> repo, T item);
}
// Usage
var repo = new Repository<Order>();
RepositoryAccessors<Order>.CallAddInternal(repo, new Order());
List<Order> items = RepositoryAccessors<Order>.GetItems(repo);
The generic parameters and their constraints must match the target exactly. If constraints don't align, the runtime throws InvalidProgramException.
Accessing types you can't reference (.NET 10+)
.NET 10 introduced [UnsafeAccessorType] to handle a scenario that [UnsafeAccessor] alone can't: accessing members on types that are invisible at compile time — internal classes in other assemblies, private nested types, or types you deliberately don't want to reference.
Instead of using the actual type in your method signature, you substitute object and annotate it with the fully-qualified type name:
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_ConnectionCount")]
static extern int GetConnectionCount(
[UnsafeAccessorType("MyLib.Internal.ConnectionPool, MyLib")] object pool);
This is particularly useful for instrumentation and diagnostic libraries that need to reach into framework internals without taking hard dependencies.
// NOTE
[UnsafeAccessorType] currently has limitations — it doesn't support field access or ref-returning methods when the type is unreferenceable. These throw NotSupportedException at runtime.
Where it shines
Unit testing is the most common use case. Rather than making members internal and adding [InternalsVisibleTo], or restructuring your code solely to satisfy test access, you can test private logic directly with zero performance cost. This is particularly valuable when you're testing edge cases in a private method that's complex enough to warrant its own test coverage.
Serialisation libraries are another natural fit. Both System.Text.Json and Entity Framework Core rely on bypassing visibility checks to populate private properties during deserialisation. Before UnsafeAccessorAttribute, this required Reflection.Emit or expression trees — neither of which works with Native AOT. The attribute gives source generators a way to emit strongly-typed accessors that work everywhere.
Source generators themselves benefit enormously. A generator that needs to read private state from user types can emit UnsafeAccessor-based code that's AOT-compatible and zero-overhead, rather than falling back to reflection at runtime.
Common pitfalls
Name matching is exact and IL-level. The Name property must match the IL metadata name, not the C# source name. Auto-property backing fields use the <PropertyName>k__BackingField convention. Local functions get mangled names. When in doubt, inspect the IL with ildasm or a decompiler.
No type hierarchy walking. The runtime only examines the exact type you specify — it won't search base classes. If the private member you're targeting is declared on a base type, you must use that base type as the first parameter.
Struct accessors need ref. As mentioned earlier, forgetting ref on the first parameter for struct instance members gives you a MissingFieldException with no helpful context.
Signature must match precisely. The return type is part of the signature match. If the private method returns int and your accessor returns long, the runtime won't find a match. The same applies to parameter types — there's no implicit conversion.
It's still unsafe access. The "Unsafe" in the name is intentional. You're bypassing the type system's visibility guarantees, which means internal refactoring in a dependency can break your accessor at runtime with a MissingMethodException. This is the same fragility that reflection has — you've just traded the performance cost for a matching contract.
// IMPORTANT
UnsafeAccessorAttribute bypasses visibility checks, not safety checks. The accessed members must still be valid and the signatures must match exactly. The attribute doesn't let you reinterpret memory or violate type safety — it just removes the private/internal gate.
Summary
UnsafeAccessorAttributeprovides compile-time-bound, zero-overhead access to private members — matching direct call performance within measurement noise.- Declare an
extern staticmethod, apply the attribute with the appropriateUnsafeAccessorKind, and the runtime supplies the implementation. - Field accessors return
ref, giving both read and write access. Property accessors targetget_/set_methods directly. - Generic type support landed in .NET 9. Access to unreferenceable types via
[UnsafeAccessorType]arrived in .NET 10. - Use it for testing private logic, building AOT-compatible serialisers, and anywhere you'd otherwise reach for
MethodInfo.Invoke. - The trade-off is the same as reflection: you're coupling to implementation details. But the performance cost is now genuinely zero, and the trimmer and AOT compiler can see exactly what you're doing.