UnsafeAccessor in C#: Access Private Members Without Reflection Overhead
There's a situation every .NET developer eventually hits: you need to reach into a class that wasn't designed to be tested, extended, or interoperated with. Maybe it's an internal class in a third-party library. Maybe it's legacy code where making fields public would require an API-breaking change. Maybe it's a domain object where private state genuinely should be private to the public API -- but you need it in a test. The classic answer has always been UnsafeAccessor in C#-adjacent reflection: FieldInfo.GetValue with BindingFlags.NonPublic | BindingFlags.Instance. It works. But it's slow, it's not AOT-safe, and the syntax is painful.
In .NET 8, a better answer arrived: UnsafeAccessorAttribute. Using UnsafeAccessor in C# is now the idiomatic way to access private internals without the overhead or AOT constraints of reflection. In .NET 10, it's more capable than ever. This article covers how it works, how to use every kind of access it supports, where it fits in your toolbox, and where it doesn't.
Disclaimer: Yes! This was written by AI based on all of the content discussed in the videos above, by yours truly, Nick Cosentino, while filming on Dev Leader. I have ensured that it represents my thoughts and perspectives.
{% youtube jp-PAViwbgg %}
The Old Way: Reflection With BindingFlags
Before UnsafeAccessor, accessing a private member looked like this:
public class OrderProcessor
{
private int _processedCount = 0;
private void IncrementCounter() => _processedCount++;
private static string _instanceId = Guid.NewGuid().ToString();
}
// Test code -- the old reflection way
var processor = new OrderProcessor();
var fieldInfo = typeof(OrderProcessor)
.GetField("_processedCount", BindingFlags.NonPublic | BindingFlags.Instance)!;
var value = (int)fieldInfo.GetValue(processor)!;
Console.WriteLine(value); // 0
This works. But every call to GetField searches through the type's metadata. GetValue boxes the integer, allocates an object on the heap, and involves at least one indirect dispatch. If you're doing this in a tight loop or in serialization hot paths, you feel it.
There's also the maintenance cost: the field name "_processedCount" is a magic string. Rename the field and the reflection call silently returns null at runtime -- no compile-time error, no IDE refactoring support.
And then there's AOT. As covered in detail in the Native AOT annotation guide, BindingFlags.NonPublic access is one of the patterns that trips the .NET trimmer. You need [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.NonPublicFields)] and careful propagation to keep these calls from being stripped.
For a comprehensive look at what traditional reflection can do, see Reflection in C#: 4 Simple But Powerful Code Examples. But understand that UnsafeAccessor exists specifically for cases where reflection's cost and fragility are the problem.
Enter UnsafeAccessor in C# (.NET 8+)
UnsafeAccessorAttribute lives in System.Runtime.CompilerServices. Instead of a runtime lookup, you declare a static extern method and tell the JIT exactly what to access via the attribute. The JIT resolves it once on the first call -- after that, it's a direct pointer dereference. No metadata search, no boxing, no indirection.
The mental model is: UnsafeAccessor in C# is a type-safe, named binding to a private member that the compiler and JIT understand natively. Unlike reflection, the compiler can see the types involved and the AOT toolchain can preserve them automatically.
using System.Runtime.CompilerServices;
internal static class OrderProcessorAccessors
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_processedCount")]
public static extern ref int GetProcessedCount(OrderProcessor processor);
}
// Usage
var processor = new OrderProcessor();
ref int count = ref OrderProcessorAccessors.GetProcessedCount(processor);
Console.WriteLine(count); // 0
count = 42; // You can write to it too -- it's a ref
Notice the ref return. For fields, UnsafeAccessor returns a reference to the field storage directly. This means you can read AND write with a single accessor, and there is no boxing. The int stays on the heap as part of the object; you're working with a managed reference to its exact location.
Syntax: The UnsafeAccessorAttribute Declaration
Every UnsafeAccessor in C# declaration follows the same shape:
[UnsafeAccessor(UnsafeAccessorKind.XXX, Name = "memberName")]
public static extern ReturnType MethodName(TargetType target, /* parameters for methods */);
Key rules:
- The method must be static.
- The method must be extern (no body -- the JIT provides it).
- The first parameter is the target instance (or for static members, it can be omitted or be a dummy).
- The
Nameproperty specifies the exact member name. If omitted, the method name itself is used as the lookup name. - For constructors, the method must return the type being constructed.
Code Example: Accessing a Private Field
public sealed class UserSession
{
private string _sessionToken = string.Empty;
private bool _isAuthenticated = false;
public UserSession(string token)
{
_sessionToken = token;
_isAuthenticated = !string.IsNullOrEmpty(token);
}
}
internal static class UserSessionAccessors
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_sessionToken")]
public static extern ref string GetSessionToken(UserSession session);
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_isAuthenticated")]
public static extern ref bool GetIsAuthenticated(UserSession session);
}
// Usage
var session = new UserSession("tok-abc123");
string token = UserSessionAccessors.GetSessionToken(session);
Console.WriteLine(token); // tok-abc123
// Force-reset for testing
UserSessionAccessors.GetIsAuthenticated(session) = false;
The ref return gives you both read and write access through the same accessor. This is especially useful in unit tests where you need to inject specific state to exercise a code path.
Code Example: Calling a Private Method
public sealed class ReportBuilder
{
private string _content = string.Empty;
private void AppendHeader(string title)
{
_content = $"=== {title} ===
";
}
private string BuildFooter(DateTimeOffset timestamp)
{
return $"Generated: {timestamp:O}";
}
}
internal static class ReportBuilderAccessors
{
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "AppendHeader")]
public static extern void CallAppendHeader(ReportBuilder builder, string title);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "BuildFooter")]
public static extern string CallBuildFooter(ReportBuilder builder, DateTimeOffset timestamp);
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_content")]
public static extern ref string GetContent(ReportBuilder builder);
}
// Usage
var builder = new ReportBuilder();
ReportBuilderAccessors.CallAppendHeader(builder, "Sales Summary");
string content = ReportBuilderAccessors.GetContent(builder);
Console.WriteLine(content); // === Sales Summary ===
string footer = ReportBuilderAccessors.CallBuildFooter(builder, DateTimeOffset.UtcNow);
Console.WriteLine(footer);
The method signature on your accessor must match the private method's parameters exactly (minus the implicit this, which becomes the first explicit parameter).
Code Example: Invoking a Private Constructor
This is one of the most powerful uses. If a class has an internal or private constructor (common in factory-pattern designs), you can invoke it directly:
public sealed class DatabaseConnection
{
private readonly string _connectionString;
private readonly int _timeoutSeconds;
// Intentionally private -- use the factory
private DatabaseConnection(string connectionString, int timeoutSeconds)
{
_connectionString = connectionString;
_timeoutSeconds = timeoutSeconds;
}
public static DatabaseConnection Create(string connectionString)
=> new DatabaseConnection(connectionString, 30);
}
internal static class DatabaseConnectionAccessors
{
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
public static extern DatabaseConnection CreateInstance(string connectionString, int timeoutSeconds);
}
// Usage
var conn = DatabaseConnectionAccessors.CreateInstance("Server=localhost;Database=test", 60);
// conn is a fully constructed DatabaseConnection with custom timeout
For the constructor case, the method name doesn't matter (constructors are always .ctor). The return type must be the constructed type, and the parameters must match the private constructor's signature.
This is a common pattern in testing frameworks and serialization libraries that need to reconstruct objects without going through the public factory. For comparison with Activator.CreateInstance patterns, see Activator.CreateInstance in C# - A Quick Rundown and Activator.CreateInstance vs Type.InvokeMember - A Clear Winner? -- those articles cover the reflection-based alternatives that UnsafeAccessor now supersedes in many scenarios.
Code Example: Accessing Static Private Members
Static fields and methods work the same way, except the first parameter convention differs: for static members, you typically omit the instance parameter (or pass the Type itself for some edge cases in older runtime versions). In .NET 10, the cleanest approach is:
public sealed class AuditLogger
{
private static int _totalEntries = 0;
private static string _logPrefix = "[AUDIT]";
private static void ResetCounters()
{
_totalEntries = 0;
}
public void Log(string message)
{
_totalEntries++;
Console.WriteLine($"{_logPrefix} {message}");
}
}
internal static class AuditLoggerAccessors
{
// For static fields, pass the type itself as a ref parameter
// (or just use the static field accessor pattern below)
[UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "_totalEntries")]
public static extern ref int GetTotalEntries(AuditLogger? unused); // Pass null for the instance when accessing static members
[UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "_logPrefix")]
public static extern ref string GetLogPrefix(AuditLogger? unused); // Pass null for the instance when accessing static members
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "ResetCounters")]
public static extern void CallResetCounters(AuditLogger? unused); // Pass null for the instance when accessing static members
}
// Usage
var logger = new AuditLogger();
logger.Log("User logged in");
int count = AuditLoggerAccessors.GetTotalEntries(null);
Console.WriteLine(count); // 1
AuditLoggerAccessors.CallResetCounters(null);
count = AuditLoggerAccessors.GetTotalEntries(null);
Console.WriteLine(count); // 0
Passing null for the instance parameter on static accessors is the convention. The JIT ignores it for static members -- it's just there to satisfy the method signature requirement.
{% youtube MCe-q5y59Oc %}
The UnsafeAccessorKind Enum
Here's the full reference for all accessor kinds available in .NET 10:
| Value | What it targets | Return type |
|---|---|---|
Field |
Instance field | ref TField |
StaticField |
Static field | ref TField |
Method |
Instance method | Same as the method's return type |
StaticMethod |
Static method | Same as the method's return type |
Constructor |
Any constructor | The declaring type |
There's no Property kind -- properties are just method pairs under the hood. Use Method or StaticMethod with get_PropertyName / set_PropertyName as the Name to access property getters and setters:
// Accessing a private property getter named "InternalStatus"
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_InternalStatus")]
public static extern string GetInternalStatus(SomeClass target);
AOT Compatibility: Why UnsafeAccessor Works
This is the key advantage over reflection. UnsafeAccessor in C# is resolved by the JIT (or AOT compiler) at first use -- it's a static binding to a known type and member. The AOT compiler can see the type reference in the extern method signature and preserve the target member automatically.
No trimmer annotations needed. No [DynamicallyAccessedMembers]. No [RequiresUnreferencedCode]. The member is visible to the compiler because it's referenced by a static extern method -- just like any other directly-called method is visible.
This makes UnsafeAccessor in C# the right tool when you're running in a Native AOT environment and still need to access private members -- a scenario where traditional reflection would require careful annotation or simply wouldn't work.
For plugin architectures where you're loading assemblies dynamically, the situation is different -- see Plugin Loading in .NET: AssemblyLoadContext with Dependency Injection for how those scenarios handle type access without UnsafeAccessor.
Limitations: What UnsafeAccessor Can't Do
UnsafeAccessor in C# is powerful but deliberately constrained. Knowing the limits helps you pick the right tool:
You must know the type at compile time. The extern method signature references the target type directly. There is no way to write an UnsafeAccessor in C# equivalent of Type.GetType(someString).GetField(someOtherString). If you need truly dynamic access -- discovering types or member names at runtime -- you still need reflection.
It doesn't work across generic instantiations in .NET 8. In .NET 8, there were restrictions around UnsafeAccessor on generic types. Generic [UnsafeAccessor] support has evolved across .NET versions -- check the official .NET release notes for the specific version you're targeting to confirm which generic scenarios are supported.
There's no equivalent for events. Events internally are a field plus add_/remove_ methods, so you can work with them via Field and Method kinds, but there's no dedicated Event kind.
It bypasses access control by design. This is intentional, but it means you're opting into responsibility for correctness. Nothing stops you from corrupting an object's internal invariants by writing bad values to private fields. Use UnsafeAccessor in C# only in controlled scenarios -- tests, interop, framework code -- not in general application logic.
When to Use UnsafeAccessor in C# vs Reflection vs Source Generators
These three tools solve related but distinct problems:
| Scenario | Best Tool |
|---|---|
| Access private members of a known type, performance matters | UnsafeAccessor in C# |
| Access private members of a known type, AOT required | UnsafeAccessor in C# |
| Discover and enumerate types/members dynamically at runtime | Reflection |
| Auto-register services, generate serializers at build time | Source generators |
| Reflection in a trimmed non-AOT app with performance needs | ConstructorInfo caching or compiled delegates |
For the middle case -- reflection in a trimmed app -- see ConstructorInfo - How To Make Reflection in DotNet Faster for Instantiation for pre-caching approaches that reduce overhead without switching to UnsafeAccessor.
For the DI integration layer, IServiceCollection in C# -- Complete Guide covers how to wire up services, and Source Generation vs Reflection in Needlr covers the source-generated path for automated registration.
Testing Use Case: Accessing Private State in Unit Tests
This is probably the most widespread legitimate use case. The debate about testing private state is real -- ideally, tests only observe behavior through the public API. But sometimes:
- A field tracks cumulative state (counters, caches) that isn't directly observable via public methods
- An integration point stores a flag that controls internal routing logic
- You're writing characterization tests for legacy code where refactoring the API is out of scope
Here's a practical test pattern:
// Production code
public sealed class RetryPolicy
{
private int _attemptCount = 0;
private readonly int _maxAttempts;
public RetryPolicy(int maxAttempts) => _maxAttempts = maxAttempts;
public bool ShouldRetry()
{
_attemptCount++;
return _attemptCount < _maxAttempts;
}
}
// Test helpers -- in a test-only assembly
internal static class RetryPolicyTestAccessors
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_attemptCount")]
public static extern ref int GetAttemptCount(RetryPolicy policy);
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_maxAttempts")]
public static extern ref int GetMaxAttempts(RetryPolicy policy);
}
// xUnit test
[Fact]
public void ShouldRetry_ReturnsTrue_UntilMaxAttemptsReached()
{
var policy = new RetryPolicy(maxAttempts: 3);
Assert.True(policy.ShouldRetry()); // attempt 1
Assert.True(policy.ShouldRetry()); // attempt 2
Assert.False(policy.ShouldRetry()); // attempt 3 -- should stop
// Verify internal state directly
int attempts = RetryPolicyTestAccessors.GetAttemptCount(policy);
Assert.Equal(3, attempts);
}
[Fact]
public void ShouldRetry_CanForceSpecificAttemptCount_ViaAccessor()
{
var policy = new RetryPolicy(maxAttempts: 5);
// Inject state to test boundary condition directly
RetryPolicyTestAccessors.GetAttemptCount(policy) = 4;
Assert.False(policy.ShouldRetry()); // Next call hits max
}
Keep test accessors in the test project, not in the production code. They're an implementation detail of your test strategy, not part of the domain model.
FAQ
Does UnsafeAccessorAttribute work with interfaces and abstract classes?
UnsafeAccessor targets concrete types. You can use it on an interface reference if you know the concrete type at compile time (cast to the concrete type first, or use the concrete type as the parameter type). It doesn't work with purely abstract/interface references where the actual backing type is only known at runtime.
Is UnsafeAccessor actually "unsafe" in the C# unsafe code sense?
No -- it doesn't require the unsafe keyword or an unsafe context. The "unsafe" in the name refers to the fact that it bypasses normal access control and the CLR's visibility rules. The code itself is managed and doesn't involve raw pointers, unmanaged memory, or unsafe blocks. The risk is logical: you can corrupt object state by misusing the ref returns, not memory safety in the traditional sense.
How does UnsafeAccessor compare to compiled delegate caching?
Compiled delegate caching (creating a Func<T, TField> via Expression.Lambda.Compile() or Delegate.CreateDelegate) is the traditional high-performance reflection workaround. It has a one-time compilation cost and then runs at near-native speed. UnsafeAccessor has zero compilation overhead -- the JIT resolves it on first call using existing type system information. For AOT scenarios, compiled delegates are not available (no runtime IL generation), making UnsafeAccessor the clear winner there.
Can I use UnsafeAccessor to access members in third-party or framework assemblies?
Yes. The extern method can target any type, including types in System.* namespaces or third-party NuGet packages. The access is resolved at runtime by member name and signature, so it works as long as the member exists in the loaded assembly. However, private members in framework types are implementation details that can change between versions -- use this carefully and add regression tests that will fail loudly if a framework upgrade removes or renames the member.
What happens if the member name specified in UnsafeAccessorAttribute doesn't exist?
The JIT throws a MissingFieldException or MissingMethodException on the first call to the accessor. This is runtime behavior, not compile-time. Unlike the strongly-typed compile-time reference you'd get with a normal method call, there's no way to detect a bad accessor name at build time unless you write tests that exercise it. This is a key reason to cover all UnsafeAccessor declarations with tests.
Can UnsafeAccessor be used in .NET Standard or older .NET versions?
UnsafeAccessorAttribute was introduced in .NET 8. It is not available in .NET Standard, .NET Framework, or any .NET runtime before 8. If you need to support older targets, you must fall back to cached reflection. A compatibility shim -- a helper that uses UnsafeAccessor on .NET 8+ and cached FieldInfo/MethodInfo on older targets -- is a reasonable pattern for multi-targeted libraries.
Is it bad practice to use UnsafeAccessor in production code?
It depends on the context. In framework infrastructure code (serializers, mappers, ORM internals, test frameworks), it's entirely appropriate -- that's exactly the use case it was designed for. In normal application business logic, if you find yourself reaching for UnsafeAccessor, it's often a sign that the design needs to change: maybe the private member should be internal, or the type should expose a controlled API for the operation you need. Use it where the alternative is reflection and the performance or AOT constraint is real.
Conclusion
UnsafeAccessor in C# fills a specific gap in the .NET toolbox that nothing else filled cleanly before .NET 8: performant, AOT-compatible, compile-time-typed access to private members. It's not a replacement for source generators in high-volume scenarios, and it's not a substitute for good API design. But when you legitimately need to reach into private state -- for testing, interop, or framework infrastructure -- UnsafeAccessor in C# is far superior to reflection in almost every measurable way.
In .NET 10, UnsafeAccessor in C# support has matured. Generic types work correctly, static member access is clean, and the JIT optimization is reliable across all platforms. If you've been tolerating reflection-based private member access in your codebase, this is the upgrade worth making.

