Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
154197 stories
·
33 followers

An EF Core alternative for .NET apps with complex object graphs — full LINQ, no migrations, no DbContext

1 Share

Today I'd like to slow down a bit and talk about redb.Core — the data engine at the heart of the RedBase ecosystem. The other pieces (redb.Route for pipelines, redb.Tsak for cluster runtime) lean on it, but this post is just about the database part.

I've been working on this project for several years. It started as an attempt to get rid of migrations and turned into what it is now — a typed object store for .NET over PostgreSQL and MSSQL.

It's not a weekend prototype. The free packages on NuGet are at version 2.0, there are 43 packages across the ecosystem, the architecture went through three rewrites, and as of this week it's been running 3 months on production at a 30-year national food distributor (more on that below).

This post is a technical walkthrough of redb.Core — what it is, how it differs from EF Core, what the generated SQL actually looks like, what the production workload looks like, and what's shipping next.

What redb.Core actually is

github.com/redbase-app/redb — Apache 2.0

RedBase stores typed C# objects in two tables (_objects + _values) over PostgreSQL or Microsoft SQL Server. Not JSON blobs. Not JSONB. Real typed columns with FK constraints — NUMERIC(38,18) for money, timestamptz for dates, uuid for GUIDs. Real B-tree indexes. ACID transactions.

The schema is your C# class:

[RedbScheme("Employee")]
public class EmployeeProps
{
    public string FirstName   { get; set; } = "";
    public string LastName    { get; set; } = "";
    public int    Age         { get; set; }
    public decimal Salary     { get; set; }
    public DateTime HireDate  { get; set; }
    public string[]? Skills   { get; set; }
    public Address? HomeAddress { get; set; }
    public Dictionary<int, decimal>? BonusByYear { get; set; }
}

That attribute is the entire schema definition. Call SyncSchemeAsync<EmployeeProps>() once — done. Add a property next sprint — redeploy, call sync again. Old objects still work. No migration files. No DBA ticket. No 2am rollback story.

// Save — entire object graph, one call
await redb.SaveAsync(employee);

// Load — full graph, arrays, dicts, nested classes — all materialized
var e = await redb.LoadAsync<EmployeeProps>(id);

// Query — real LINQ, real SQL
var seniors = await redb.Query<EmployeeProps>()
    .Where(e => e.Salary > 100_000 && e.Age >= 35)
    .OrderByDescending(e => e.Salary)
    .Take(50)
    .ToListAsync();

No DbContext. No Include chains. No Add-Migration. No mapper layer.

Object graphs in one call

This is the part that surprises people coming from EF. Props can contain other Props — single references, arrays, dictionaries — and the entire graph saves and loads as one operation:

[RedbScheme("Order")]
public class OrderProps
{
    public Customer Customer { get; set; }                          // nested class
    public Address ShippingAddress { get; set; }                    // nested class
    public Product[] Products { get; set; }                         // array of classes
    public RedbObject<PaymentProps>[] Payments { get; set; }        // array of full objects with own IDs
    public Dictionary<string, RedbObject<CouponProps>> Coupons { get; set; }  // dict of objects
    public Dictionary<(int Year, string Quarter), string> Reviews { get; set; } // tuple-key dict
}

await redb.SaveAsync(order);   // entire graph persisted, FK ordering handled
var loaded = await redb.LoadAsync<OrderProps>(id);
// loaded.Props.Customer.Address — ready
// loaded.Props.Payments[0].Props — ready (full RedbObject with own Id, DateCreate, etc.)
// loaded.Props.Coupons["SUMMER20"].Props — ready

In EF Core this would be 28 tables, ~40 Include/ThenInclude calls, manual junction tables for the many-to-many, and INSERT ordering that breaks every time someone adds a non-nullable FK without a default.

In RedBase: one SaveAsync, one LoadAsync. The nested RedbObject instances are real first-class objects — they have their own IDs, their own timestamps, they can be queried independently, they participate in tree structures. They are not denormalised JSON glued to the parent.

What the LINQ actually compiles to

Where(e => e.Salary > 100_000 && e.Age >= 35) doesn't get serialized to JSON and re-parsed (that's the Free engine's path — covered later). In Pro, the C# expression tree is walked node-by-node and emitted as parameterized SQL. Roughly:

WITH pvt AS (
  SELECT v._id_object,
         (array_agg(v._Numeric) FILTER (WHERE v._id_structure = $1))[1] AS "Salary",
         (array_agg(v._Long)    FILTER (WHERE v._id_structure = $2))[1] AS "Age"
    FROM _values v
   WHERE v._id_structure = ANY($3::bigint[])
     AND v._id_object IN (SELECT o._id FROM _objects o WHERE o._id_scheme = $4)
   GROUP BY v._id_object
)
SELECT o.*
  FROM _objects o
  JOIN pvt ON pvt._id_object = o._id
 WHERE pvt."Salary" > $5
   AND pvt."Age"    >= $6
 ORDER BY pvt."Salary" DESC
 LIMIT 50;

Parameterized. Plan-cached by PostgreSQL. One index scan on (_id_structure, _id_object), one aggregation pass, B-tree filter on flat columns. The number of filter fields doesn't change the shape of the query.

The C# → SQL compiler handles arithmetic (*, +, %), Math.*, String.Contains/StartsWith/Trim/ToLower, DateTime.Year/Month/..., nullable navigation (x.Address?.City) compiled to IS NOT NULL, the ternary operator compiled to CASE WHEN, StringComparison.OrdinalIgnoreCase compiled to native ILIKE, dictionary access dict["key"] compiled to pivot columns, and a few more edge cases that EF Core itself doesn't always handle.

You can preview the SQL of any query without executing it:

var sql = await query.ToSqlStringAsync();

Like IQueryable.ToQueryString() in EF, but works for trees, GroupBy, window functions too.

Why bulk save is so fast — two tables, two streams

Something to understand before the change-tracking section: the storage layout is two tables, so SaveAsync of a batch is two bulk operations, not N round-trips.

On PostgreSQL the provider uses Npgsql's BeginBinaryImportAsync — native COPY protocol, binary format:

// from redb.Postgres/Data/NpgsqlBulkOperations.cs
await using var writer = await conn.BeginBinaryImportAsync(
    "COPY _objects (_id, _id_parent, _id_scheme, _name, ...) FROM STDIN (FORMAT BINARY)");
foreach (var obj in objectsList) {
    await writer.StartRowAsync();
    await writer.WriteAsync(obj.Id,        NpgsqlDbType.Bigint);
    await writer.WriteAsync(obj.IdScheme,  NpgsqlDbType.Bigint);
    // ... typed writes
}
await writer.CompleteAsync();

For a batch save: one COPY stream writes the _objects rows, another writes the _values rows. Two streams, no per-row round-trips, no string-formatted INSERTs. MSSQL uses SqlBulkCopy for the same role.

This is why 1000 routes × ~40 fields = ~40,000 value rows save in tens of milliseconds inside the 200–300 ms budget. The bottleneck is the network round-trip and the COPY write, not the ORM machinery — there isn't any ORM machinery in the hot path.

Change tracking without DbContext (Pro)

DbContext keeps an in-memory snapshot of every entity you load — that's how it knows what changed. It's also why it isn't thread-safe and why the cache dies with the request.

RedBase Pro takes a different approach. With PropsSaveStrategy.ChangeTracking (Pro only; the free tier uses DeleteInsert), SaveAsync does this on a batch:

  1. One bulk SELECT of existing values for all object IDs being saved.
  2. Build two ValueTreeNode trees — one from your in-memory objects, one from the DB state.
  3. Structural diff — subtrees with matching hashes are skipped entirely (no value comparison, no child traversal). Inserts, updates, and deletes are computed per node.
  4. Three bulk operationsBulkInsertValuesAsync, BulkUpdateValuesAsync, BulkDeleteValuesAsync — each a single round-trip. Inserts go through COPY BINARY again.

Net effect: changing one field in a deeply nested object emits one UPDATE, not a full delete-and-reinsert of the entire props graph. And the comparison happens in C# on the application side — no DbContext lifetime to worry about, safe to run from a background Channel consumer or a Parallel.ForEach.

(In the production code from the previous section, the application also does its own obj.ComputeHash() check at the route level. That part is optional business-code — you could call SaveAsync on every route and the Pro tree-diff would still skip unchanged values internally. It's there as a coarse pre-filter so unchanged routes don't even enter the save pipeline at all, saving the diff work too.)

Production deployment — the numbers

The biggest deployment right now:

  • 30-year national HoReCa food distributor
  • ~150,000 orders/month, ~20,000 B2B customers, 600+ cities
  • 3-node cluster, 4 cores / 8 GB RAM / 50 GB SSD per node
  • ~550 daily internal users (operators, drivers, supervisors, dispatch, back-office)
  • 10–15% CPU under full load
  • Integrations: SAP, Kafka, RabbitMQ, GPS feeds, Mercury / EGAIS / government APIs

Three months in production, no data-layer incidents. Two projects in the company use it, the second one came after the first one proved stable.

Real workload, real timings

The hottest pipeline is the SAP monitoring sync. Every 60 seconds a SQL polling consumer calls a stored procedure on SAP S/4 (usp_TsUM_MonitoringReport_xml), gets ~1000 transportation orders back as XML, syncs reference dictionaries (drivers, vehicles, list items), bulk-loads existing routes from RedBase, hash-compares each one, and saves only the changed ones.

The whole loop fits in ~200–300 ms for ~1000 routes. The actual production code:

From("direct://tsum")
    .RouteId("tsum-processing")
    .ProcessWithRedb(async (redb, exchange, ct) =>
    {
        var orders = (List<TransportationOrder>)exchange.In.Body;

        var sw = Stopwatch.StartNew();

        // 1. Sync dictionaries (Drivers, Vehicles, ListItems) — only new/changed
        var dicts = await DictionarySyncService.SyncFromOrdersAsync(redb, orders, ct);
        if (dicts.DriversNew + dicts.DriversChanged + dicts.VehiclesNew
            + dicts.VehiclesChanged + dicts.ListItemsRelinked > 0)
            await RefDataCache.RefreshAsync(redb);
        var syncMs = sw.ElapsedMilliseconds;

        // 2. Bulk load existing routes by Code — one query, ~1000 codes
        var codes = orders.Select(o => o.Code).ToList();
        var existing = await redb.Query<TransportationRoute>()
            .WhereRedb(o => codes.Contains(o.ValueString!))
            .ToListAsync();
        var existingByCode = existing.ToDictionary(o => o.ValueString!, o => o);

        // 3. Merge: update if hash changed, insert if new, skip if unchanged
        var toSave = new List<IRedbObject>();
        var updatedCount = 0;
        var skippedCount = 0;
        foreach (var order in orders)
        {
            var routeProps = MapOrderToRouteProps(order, dicts);
            if (existingByCode.TryGetValue(order.Code, out var obj))
            {
                var hashBefore = obj.ComputeHash();
                EnrichRouteFromOrder(obj.Props!, routeProps);
                if (obj.ComputeHash() != hashBefore) { toSave.Add(obj); updatedCount++; }
                else skippedCount++;
            }
            else
            {
                toSave.Add(new RedbObject<TransportationRoute>
                {
                    name = $"Route {order.Code}",
                    value_string = order.Code,
                    Props = routeProps
                });
            }
        }

        // 4. One batched save — mixed inserts + updates
        if (toSave.Count > 0)
            await redb.SaveAsync(toSave);

        Logger.LogInformation(
            "[TSUM] orders={Orders} routes(+{Created} ~{Updated} ={Skipped}) " +
            "drivers(+{DN} ~{DC}) vehicles(+{VN} ~{VC}) " +
            "sync={Sync} query={Query} save={Save} total={Total}ms",
            orders.Count, toSave.Count - updatedCount, updatedCount, skippedCount,
            dicts.DriversNew, dicts.DriversChanged,
            dicts.VehiclesNew, dicts.VehiclesChanged,
            syncMs, queryMs, saveMs, sw.ElapsedMilliseconds);
    });

TransportationRoute has ~40 fields and 12 RedbListItem references (Driver, Vehicle, CarMark, ShippingPoint, BusinessType, PlaceTo, PlaceFrom, LoadingZone, TransportStatus, Risk, DeliveryStatus, LoadStatus) plus 2 object references to AD users. Every single one is a foreign key in the database. None of them require a JOIN at query time — the materializer handles it.

Smaller queries (point-lookup of a single route, dictionary fetch, REST endpoints for the UI) run in 50–100 ms including HTTP overhead.

What's actually in the database

After running this in production, the storage looks like this:

  • ~1000 transportation routes/day, plus delivery points, transport snapshots, garage states, slice settings, slice snapshots, drivers, vehicles, yard places, AD user refs — about a dozen [RedbScheme] classes in active use
  • ~500,000 objects in _objects (routes, points, snapshots, dictionary items)
  • ~15,000,000 rows in _values (every typed property of every object)
  • All of that lives in 2 tables_objects and _values — plus the system tables for schemes, structures, lists, users, permissions

15 million typed value rows. Two tables. No 30-table schema. No 200 migration files. The query times above are on this dataset.

If you tried to model the same domain in EF Core flat tables, you'd end up with roughly: Routes, RoutePoints, TransportSnapshots, GarageStates, SliceSettings, SliceSnapshots, Drivers, Vehicles, CarMarks, ShippingPoints, BusinessTypes, YardPlaces, LoadingZones, TransportStatuses, DeliveryStatuses, Risks, TripRisks, AdUsers — plus junction tables and audit tables for each. 30+ tables, dozens of migrations, and every schema change is a deploy event.

In RedBase the schema change is git push. The next InitializeAsync() call adds the new structure rows. Done.

What would EF Core look like here?

I asked myself the same question before starting. Let's count what EF would need:

  • TransportationRoute entity → 1 table
  • 12 ListItem references → 12 lookup tables + 12 nullable FKs
  • 2 AD user references → 2 more FKs
  • The 1000-route batch update → 1000 entities tracked in DbContext for change detection
  • Hash-based skip of unchanged objects → doesn't exist in EF out of the box; you write it manually
  • The same logic across multiple processes/routes → DbContext is per-request, the change-tracker cache dies at the end of every batch

The realistic EF flow looks like this:

// Load existing — needs Include for every lookup or you get N+1
var existing = await db.Routes
    .Include(r => r.Driver)
    .Include(r => r.Vehicle)
    .Include(r => r.CarMark)
    .Include(r => r.ShippingPoint)
    .Include(r => r.BusinessType)
    .Include(r => r.PlaceTo)
    .Include(r => r.PlaceFrom)
    .Include(r => r.LoadingZone)
    .Include(r => r.TransportStatus)
    .Include(r => r.Risk)
    .Include(r => r.DeliveryStatus)
    .Include(r => r.LoadStatus)
    .Include(r => r.RiskSetBy)
    .Include(r => r.KcResponsible)
    .Where(r => codes.Contains(r.Code))
    .ToListAsync();

// Map, mutate, SaveChanges — 1000 tracked entities, full diff snapshot in RAM
// SaveChanges fires N UPDATE statements (one per row) in a transaction
await db.SaveChangesAsync();

In practice on a workload like this:

  • The Include chain on 14 lookups produces a query that PostgreSQL/MSSQL can't always optimise cleanly (cartesian explosion risk; you split-query and pay multiple round trips).
  • SaveChanges on 1000 modified entities emits 1000 individual UPDATEs unless you reach for EFCore.BulkExtensions or similar third-party libraries. RedBase ships COPY BINARY for inserts and a batched UPDATE path for updates in the box.
  • The DbContext snapshot of 1000 tracked entities, each with 14 included lookups, is real memory pressure. AsNoTracking() is faster but you lose change detection — you have to re-implement it.
  • The 200–300 ms budget on a 4-core container is not realistic in this scenario without significant manual optimisation and probably a separate bulk-update path.

I'm not saying EF can't do it. I'm saying the equivalent EF implementation is more code, more moving parts, and significantly slower without third-party packages. RedBase ships a single SaveAsync(toSave) and a hash-based skip primitive in the box.

The hash comparison itself (obj.ComputeHash() != hashBefore) is application-level business code — it's optional; the Pro tree-diff inside SaveAsync would skip unchanged values anyway. But used like this it lets the application skip the ~95% of routes that didn't change between SAP polls before they even enter the save pipeline. Combined with COPY BINARY into two tables and the Pro tree-diff for the routes that do change, the whole loop fits the 200–300 ms budget. No equivalent set of built-in primitives exists in EF Core; you either snapshot manually, re-save everything, or pull in third-party bulk packages.

What EF Core does with complex objects (vs what RedBase does)

The E000 benchmark uses EmployeeProps — a realistic model with nested classes, arrays, dictionaries, and RedbObject references:

What classic ORM needs RedBase
~28 tables 2 tables
FK ordering on every INSERT Single SaveAsync
40+ Include/ThenInclude calls LoadAsync<T>(id) — one line
Migration file for every new field Add property, call SyncSchemeAsync, done
~5,000 INSERTs for 100 employees 1 BulkInsert via COPY protocol

For 100 complex employees: EF Core ≈ 4,000–6,000 INSERTs across 28 tables in FK order. RedBase: ~3,000 typed value rows, one COPY command.

And the test results are published — 525 automated tests across all editions and both databases. All green.

The query engine: free vs Pro

This is where it gets technically interesting.

Free edition compiles your LINQ lambda to a JSON facet format, then calls a plpgsql stored procedure that parses that JSON and generates SQL dynamically. For simple 1–2 field filters, PostgreSQL optimizes correlated EXISTS subqueries well. It works.

Pro edition walks the C# expression tree directly in C# (ExpressionToSqlCompiler), emits native parameterized SQL. Same plan every call — PostgreSQL can cache it. 3–10× faster on complex multi-field filters.

But here's what's happening right now: I'm porting the Pro PVT CTE query engine into the free tier. The plan is full parity on PVT — same CTE shape, same expression coverage, same projection capabilities. Free and Pro will speak the same query dialect.

The remaining differences between Free and Pro will be elsewhere:

  • Query plan caching. Pro builds parameterized SQL in C# (ExpressionToSqlCompiler) — same shape every call, PostgreSQL caches the plan once and reuses it. The free tier generates SQL inside plpgsql with literal values inlined (properly escaped to prevent injection), so each call can produce a slightly different plan. Same correctness, different plan-cache behavior.
  • Materialization. Pro has a parallel materializer (multiple values streams hydrated concurrently into the object graph). Free uses a simpler sequential path.
  • Save strategy. PropsSaveStrategy.ChangeTracking (tree-diff with hash-based subtree skip) is Pro-only. Free uses DeleteInsert — still bulk via COPY, but no per-field diff.

So Pro stays faster on hot paths and large object graphs, but the query language itself — what you can write in .Where, .Select, projections, grouping, having, FTS, regex — will be the same on both sides.

Show me the SQL it generates

Here's a real example — a projection query on EmployeeProps with 16 computed columns:
full names, salary calculations, date extractions, seniority classification via CASE, coalesce for nullable fields, explicit type cast. This is the free tier:

WITH _pvt_cte AS (
    SELECT
        v._id_object,
        (array_agg(v._Long)    FILTER (WHERE v._id_structure = 1000017))[1] AS "Age",
        (array_agg(v._Numeric) FILTER (WHERE v._id_structure = 1000020))[1] AS "Salary",
        (array_agg(v._DateTimeOffset) FILTER (WHERE v._id_structure = 1000018))[1] AS "HireDate",
        (array_agg(v._String)  FILTER (WHERE v._id_structure = 1000016))[1] AS "LastName",
        (array_agg(v._String)  FILTER (WHERE v._id_structure = 1000015))[1] AS "FirstName"
    FROM _values v
    WHERE v._id_structure = ANY(ARRAY[1000015,1000016,1000017,1000018,1000020]::bigint[])
      AND v._id_object IN (SELECT o._id FROM _objects o WHERE o._id_scheme = 1000014)
    GROUP BY v._id_object
)
SELECT
    o._id                                                          AS id,
    ("FirstName" || ' ' || "LastName")                            AS full_name,
    UPPER("FirstName")                                             AS upper_name,
    LENGTH("FirstName")                                            AS name_len,
    ("Salary" * 12)                                                AS yearly_x12,
    (("Salary" * 0.15) - ("Salary" * 0.013))                      AS bonus_after_tax,
    ("Salary" / ("Age" + 1))                                       AS avg_per_year,
    ("Age" % 10)                                                    AS age_mod10,
    ABS(("Age" - 35))                                              AS abs_diff,
    FLOOR(("Salary" / 2))                                          AS floor_half_salary,
    EXTRACT(YEAR  FROM "HireDate")                                 AS hire_year,
    EXTRACT(MONTH FROM "HireDate")                                 AS hire_month,
    EXTRACT(YEAR  FROM AGE("HireDate", '2026-05-20'))              AS tenure_years,
    CASE
        WHEN ("Age" >= 60) THEN 'senior'
        WHEN ("Age" >= 35) THEN 'mid'
        ELSE 'junior'
    END                                                            AS seniority,
    COALESCE("FirstName", '<unknown>')                             AS display_name,
    ("Age")::text                                                  AS age_as_text
FROM _pvt_cte
JOIN _objects o ON o._id = _pvt_cte._id_object
WHERE "Salary" > '0'::numeric
ORDER BY "Salary" DESC
LIMIT 3

One _values scan. One GROUP BY. Sixteen computed output columns. Standard SQL — no magic, fully readable in pgAdmin, fully EXPLAIN ANALYZE-able.

This query was generated by pvt_build_projection_sql() — a plpgsql function that is shipping in the next free release.

When this fits and when it doesn't

Honest take.

Fits well: business apps with collections, nested structures, hierarchies, dictionaries, or schemas that change often. Apps where a business analyst can ask "add a field" and you want to ship the same day. Anything where you'd otherwise end up with 28 tables for one logical entity.

Doesn't fit: flat reporting databases with two or three fixed tables. Use Dapper there — it's honest and fast.

The common pushback is: "EF Core is good enough for 80% of projects." We'd flip that around: RedBase fits 80% of typical business projects — anything with collections, nested structures, lookup tables, hierarchies, dictionaries, or a schema that evolves alongside the product. The 20% where it's overkill is the genuinely flat case: two or three fixed tables, pure reporting, no evolving model. Use Dapper there; it's honest and fast. For everything else — which is most business software — the EF migration tax is real and it compounds with every sprint.

Three more axes that usually get left out of that comparison:

  • Extensibility. Adding a field is a property in C# and one InitializeAsync() call — no migration script, no review, no deploy window. The same change in an EF stack is at minimum: model edit + Add-Migration + reviewed SQL + coordinated deploy. Multiply by every iteration in a year.
  • Floor skill level. With RedBase, a junior developer who knows C# can add entities, fields, queries, and relationships on day one — the schema is just a class, the query is just LINQ. With EF Core on a complex model, you need to understand N+1, cartesian explosion from Include chains, DbContext lifetime, migration conflicts on shared branches, and when to reach for AsNoTracking(). That knowledge takes months to build and years to apply consistently. RedBase moves that complexity into the framework and out of the application code.
  • Cost of development. On the TsUM project the data-layer work — schemas, storage, queries, sync pipelines, change-tracking, bulk save — came in at roughly 128 person-hours. The estimate for the same scope on a classic EF + handwritten bulk-update + ASP.NET Identity + 30-table migration stack was on the order of 3000 person-hours. That's not a typo and it's not marketing — it's the gap between "add a property, redeploy" and "add a column, write migration, update DTOs, update mapper, update repository, write tests for all of it". The 80%/20% framing only holds if you count lines of code; once you count engineer-hours over a project lifetime it inverts.

What else is in the box

195+ working examples in the repo. The shortlist of things people usually don't expect to be built-in:

// Tree queries — recursive CTE underneath, no CTE to write yourself
var products = await redb.TreeQuery<Product>(londonHQ.Id, maxDepth: 10)
    .Where(p => p.InStock)
    .ToListAsync();

// Soft delete with atomic mark + background purge + progress in DB
await redb.SoftDeleteAsync(ids, user);

Also shipping in the core (this is the short list — the real list is much longer):

  • GroupBy + window functions in one query
  • Built-in users / roles / permissions (no ASP.NET Identity dependency, no separate auth schema)
  • Export / import via .redb files (PostgreSQL ↔ MSSQL portability)
  • Multi-database in one process with full domain isolation — separate connections, separate scheme caches, separate object caches per domain; one app can talk to several independent RedBase databases at the same time without their metadata leaking into each other
  • Scheme cache and object cache (both per-domain), invalidated on SyncSchemeAsync / SaveAsync, used by the materializer to skip repeated metadata lookups
  • Atomic soft-delete with background purge and progress visible in the DB
  • Tree queries with recursive CTE
  • Polymorphic trees — different C# types at each level of the same tree

There's a lot more that doesn't fit in one section. The architecture page has the full map.

What's coming next

The FreePvtQuery release is the nearest milestone. This is a complete rewrite of the free tier's query engine based on the PVT CTE architecture — bringing it to full parity with Pro on the query side:

  • Single-pass _values scan (vs correlated EXISTS per field)
  • Full expression system: arithmetic, string, date, math, regex, FTS, CASE, coalesce, cast
  • Projections with arbitrary computed columns (like the SQL above)
  • HAVING, DISTINCT ON, GROUP BY extensions

After that: MSSQL support for the new engine, C# LINQ-to-JSON bridge so you keep writing .Where(e => e.Salary > 100_000) without touching JSON. Pro will keep its edge on plan caching (parameterized SQL), parallel materialization, and tree-diff change tracking.

This is maybe 10% of what's in there

This post covered the basics: storage model, query engine, free PVT demo, production deployment. Topics I didn't touch:

  • Polymorphic tree hierarchies (different C# types at every level, same tree API)
  • Object graphs with RedbObject<T> references inside Props
  • Data migrations (Pro): fluent API, computed columns, in-place type changes
  • Export/import: .redb files (JSONL/ZIP), PostgreSQL ↔ MSSQL portability
  • Multi-database in one process (domain-isolated caches)
  • redb.CLI — schema management from command line
  • redb.PropsEditor — runtime props editing UI
  • Integration with redb.Route (22-transport pipeline engine) — separate article

If there's a direction you want covered next — migrations, polymorphic trees, benchmarks, internals of the diff-tree change tracking — drop it in the comments and that's what the next post will be.

Links

There's a lot of material. If you want to go deep without reading three articles, paste these into your AI of choice and ask it to analyse the architecture:

Questions in the comments — I'll answer them directly.

Read the whole story
alvinashcraft
just a second ago
reply
Pennsylvania, USA
Share this story
Delete

Announcing Agent Governance Toolkit MCP Extensions for .NET

1 Share

The Model Context Protocol (MCP) has made it much easier to connect tools and resources to AI applications. But once those tools are exposed to agents, you also need a reliable way to govern what gets registered, what gets executed, and what comes back from tool calls.

For a detailed look at the governance patterns and controls behind this package, see Governing MCP tool calls in .NET with the Agent Governance Toolkit. This post announces Microsoft.AgentGovernance.Extensions.ModelContextProtocol, a Public Preview companion package for the official MCP C# SDK that makes it simple to apply those controls.

It adds one-call governance to IMcpServerBuilder so you can apply policy enforcement, startup scanning, runtime tool-call governance, and response sanitization to your MCP server without building that plumbing yourself.

If you’re already building MCP servers with .NET, this package is designed to fit directly into the builder pipeline you already use.

Why MCP servers need governance

MCP makes tool integration straightforward, but the same flexibility creates a new set of security and reliability questions:

  • Should every registered tool be callable by every agent?
  • What happens if a tool description includes prompt-injection-style instructions?
  • How do you fail closed when a tool definition changes in a risky way?
  • How do you keep unsafe tool output from flowing straight back into the model?

Those concerns usually show up as a mix of custom filters, ad hoc validation, and application-specific guardrails. Microsoft.AgentGovernance.Extensions.ModelContextProtocol packages those concerns into a single extension method so the secure path is also the simple path.

Getting started

Here’s how to add it to your MCP server. Start by installing the package:

dotnet add package Microsoft.AgentGovernance.Extensions.ModelContextProtocol

Then add governance when configuring your MCP server:

using AgentGovernance.Extensions.ModelContextProtocol;

builder.Services
    .AddMcpServer()
    .WithGovernance(options =>
    {
        options.PolicyPaths.Add("policies/mcp.yaml");
        options.DefaultAgentId = "did:mcp:server";
        options.ServerName = "contoso-support";
    });

That single call registers startup and runtime governance controls in one place: tool-definition scanning before exposure, identity-aware policy enforcement on each call, response sanitization before model return, and audit plus metrics instrumentation.

What WithGovernance(...) adds

The package is intentionally small on surface area and opinionated in behavior.

How the governed flow works

The flow has two phases: startup gating and runtime tool-call governance. Startup scanning happens before tools are exposed, while runtime checks apply on each tool call.

Diagram of governed MCP flow showing startup scanning, runtime policy checks, and response sanitization

Startup scanning for unsafe tool definitions

When MCP server options are materialized, the package scans registered tools before they are exposed. By default, unsafe tools fail startup.

This is a startup gate, not a per-call runtime step, so unsafe tool metadata can fail closed before any tool is exposed to clients.

The built-in scanner detects threat categories including:

  • tool poisoning
  • typosquatting
  • hidden instructions
  • rug pulls
  • schema abuse
  • cross-server attacks
  • description injection

This helps detect problems such as prompt-like control text in descriptions, suspiciously similar tool names, hidden Unicode characters, or schema fields that request sensitive values like token, password, or system_prompt. Detection effectiveness depends on your threshold tuning and threat model—tune the risk score threshold in your own environment based on your acceptable false-positive rate.

Policy enforcement on tool execution

Governance decisions are applied when tools are invoked, using the same Agent Governance policy model as the base .NET package.

That means you can use YAML-backed policies to decide which tools are allowed, denied, or rate-limited, and you can keep those rules outside of application code.

For example:

apiVersion: governance.toolkit/v1
version: "1.0"
name: mcp-governance-policy
default_action: deny
rules:
  - name: allow-echo
    condition: "tool_name == 'echo'"
    action: allow
    priority: 10

If a tool call is denied, the package returns a governed error result instead of letting execution continue.

Authenticated identity support

When an authenticated identity is present, governance uses that agent identity in evaluation. If one is not available, the package falls back to a configurable default DID such as did:mcp:anonymous.

This makes it easier to write policies that distinguish between trusted callers and anonymous or low-trust execution contexts.

Response sanitization before content reaches the model

Tool output is another place where attacks can hide. By default, the package sanitizes text responses before they are returned to the client.

The sanitizer scans for:

  • prompt-injection tags like <system>...</system>
  • imperative override phrasing like “ignore previous instructions”
  • credential leakage patterns
  • exfiltration-oriented URLs

When it finds patterns matching these categories, it redacts the dangerous fragments while preserving as much useful result content as possible. Sanitizer effectiveness depends on pattern tuning and your environment’s threat baseline.

Designed to fail closed by default

One of the goals of this package is to make safe defaults the default defaults.

McpGovernanceOptions enables several protections out of the box:

  • ScanToolsOnStartup = true
  • FailOnUnsafeTools = true
  • SanitizeResponses = true
  • GovernFallbackHandlers = true
  • EnableAudit = true
  • EnableMetrics = true

That combination gives you a strong baseline without requiring a long checklist before your first deployment.

Works with the MCP builder model you already use

This package doesn’t require a forked SDK, a separate proxy process, or a custom server abstraction. It extends the official C# SDK builder and wraps the final ToolCollection, so governance applies to tools registered before or after the extension is added.

That detail matters for real applications, because MCP server setup often grows across feature modules and DI registrations over time.

A practical fit for production MCP servers

Microsoft.AgentGovernance.Extensions.ModelContextProtocol is a good fit when you want to:

  • add policy control to an existing MCP server
  • block unsafe tool definitions before startup completes
  • enforce identity-aware tool execution
  • sanitize tool output before it is fed back into agent workflows
  • standardize governance across multiple MCP servers in the same organization

Because the package builds on the broader Microsoft.AgentGovernance stack, it also lines up with features like auditability, metrics, execution rings, prompt-injection detection, and circuit-breaker support already available in the .NET package.

Try it today

Microsoft.AgentGovernance.Extensions.ModelContextProtocol is available now as a Public Preview package for .NET 8+ applications using the official MCP C# SDK.

To get started:

  1. Install Microsoft.AgentGovernance.Extensions.ModelContextProtocol.
  2. Add WithGovernance(...) to your IMcpServerBuilder pipeline.
  3. Point the package at your governance policy files.
  4. Run your server with startup scanning and response sanitization enabled.

If you’re building MCP servers for internal copilots, enterprise tools, or agent platforms, this gives you a straightforward way to add governance support to your MCP servers without re-implementing the same controls in every service.

Compliance note

Agent Governance Toolkit provides technical controls that can help support security and privacy programs. It does not, by itself, guarantee legal or regulatory compliance. You are responsible for validating your end-to-end implementation, data handling, and operational controls against applicable requirements (for example, GDPR, SOC 2, or your internal policies).

Resources

The post Announcing Agent Governance Toolkit MCP Extensions for .NET appeared first on .NET Blog.

Read the whole story
alvinashcraft
just a second ago
reply
Pennsylvania, USA
Share this story
Delete

Improving C# Memory Safety

1 Share

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.ReadByte pass an IntPtr and offset that together address a byte the program is allowed to read. The cautionary difference from GetString is that ReadByte doesn’t perform any input validation and is callable from safe code today. The try/catch clause doesn’t offer any safety, but is used to change the exception type, for only one scenario of misbehavior. The reason this is considered OK is that Marshal and Unsafe are conventionally understood to be unsafe to call.

We can dissect the method a bit further. Today’s unsafe signature on ReadByte establishes an unsafe context for the implementation but doesn’t create a caller contract or document a caller warning. The existing model propagates unsafety through pointer types in signatures, but IntPtr dodges that rule; the API is effectively pointer smuggling.

The new model closes this gap. It widens unsafety to cover any operation that can violate the live-memory invariant (not just operations involving pointer types), and makes the unsafe signature marker the member contract, with inner unsafe blocks encapsulating the unsafe operations. It also aligns the safety character of IntPtr and pointers like byte*: both can be held, assigned, and exposed in signatures outside an unsafe block; it is pointer dereference that is unsafe.

ReadByte changes with the new model, per the following mockup:

/// <summary>Reads a single byte from unmanaged memory.</summary>
/// <safety>
/// The sum of <paramref name="ptr"/> and <paramref name="ofs"/> must address a byte
/// the caller is permitted to read.
/// </safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs)
{
    try
    {
        byte* addr = (byte*)ptr;
        unsafe
        {
            // SAFETY: relies on caller obligation.
            return addr[ofs];
        }
    }
    catch (NullReferenceException)
    {
        throw new AccessViolationException();
    }
}

Let’s dig into the implementation. The cast (byte*)ptr is pointer manipulation, not a dereference; IntPtr and byte* are the same shape, different representation; both are just a number. The unsafety is on a single line: return addr[ofs]. That is the point where the developer needs to attest that addr + ofs addresses readable memory, since the indexing dereferences that address. byte*byte requires copying memory from the pointer address into a value. That’s the dangerous operation.

The new model works because the pointer dereference, addr[ofs], gets wrapped in an unsafe block, shining light on the unsafety. The unsafe signature becomes a caller contract, forcing callers to wrap their calls in an unsafe block as well, and a reminder to look at the callee safety doc.

A strict “smallest unsafe block” reading would put the + ofs arithmetic outside the block, since arithmetic on its own isn’t a dereference. We prefer to keep addr[ofs] together: indexing is the indirection (addr[0] is by spec the same as *addr), and grouping makes the exact address being read visible at the point of access. We expect these kinds of choices to be codified in unsafe coding guidelines over time.

Violations are compile errors, not warnings. The model isn’t an “honor system”. Take Marshal.ReadByte from above: it is marked unsafe because its implementation dereferences an opaque caller-supplied pointer. In the new model, it will continue to be marked unsafe because it passes a pointer validity obligation on to callers. The obligation was previously understood by convention. The compiler now requires Marshal.ReadByte to expose the obligation as a contract.

Propagation and suppression

The safety marking system established by Rust is a good guide for propagation and suppression. C# 16 is adopting the same approach and syntax. The unsafe keyword is used in two ways. The first is an inner unsafe block that wraps an unsafe operation, typically due to calling another unsafe method and/or dereferencing a pointer. The second is an outer unsafe signature marker that defines a caller contract.

To propagate unsafety to the caller, the developer adds unsafe to the member signature; to suppress unsafety as an implementation detail, they leave unsafe absent. Presence or absence of unsafe on a member signature (for methods with inner unsafe) is the compiler signal for propagation or suppression. Propagation pushes unsafety one caller higher while suppression caps unsafety by offering a safe-caller-compatible surface area.

C# 1.0 model

C# 1.0 uses unsafe on a type or member to mean “unsafe context from this point”. It doesn’t inform or change the caller contract. Pointers are the sole propagation mechanism in C# 1.0. Inner unsafe can be used to tighten the scope of unsafety.

Let’s start with code that is legal today, in the C# 1.0 model.

void Caller()
{
    M();
}

unsafe void M() { }

Caller can call unsafe M without any ceremony.

The reason is twofold:

  • unsafe is being used to create an inner unsafe block for the entire method, not to define a caller contract.
  • M doesn’t expose pointers, so doesn’t propagate unsafety.

This example is analogous to ReadByte. Caller could call ReadByte just as freely as it is calling M. It could not call Encoding.GetString in the same way due to pointer usage.

We need to critique the existing model to understand why we are moving away from it. The roles and responsibilities of M and Caller are specified only by convention. There is no standard for the safety concerns or obligations that M should communicate to Caller or how Caller meets the expectations of its safe callers. In short, there is no overarching system that pushes developers towards actual safety or that enables straightforward auditing. Safety is currently deployed by skilled engineers who understand how to define obligations and risks, without help from the compiler.

C# 16 model

The new model adopts unsafe on a method signature as a caller-facing propagation mechanism. The absence of unsafe is used to communicate suppression.

Caller from the previous example would have to be adjusted to either Caller1 or Caller2 below.

/// <safety>
/// Caller must satisfy obligation 1
/// </safety>
unsafe void Caller1()
{
    unsafe
    {
        // SAFETY: Obligation is passed to caller.
        M();
    }
}

void Caller2()
{
    if (/* obligation 1 not satisfied */) throw new Exception();

    unsafe
    {
        // SAFETY: obligation 1 is discharged by the check above
        M();
    }
}

/// <safety>
/// Caller must satisfy obligation 1
/// </safety>
unsafe void M() { }

Both M and Caller1 propagate unsafety to their callers. Caller2 suppresses the unsafety of its callees and is an unsafe boundary method. Either form is a valid replacement for Caller. The developer decides which is appropriate based on whether it is possible or desirable to validate obligation 1. If caller obligations remain, then Caller1 is the right choice. Choosing between propagation and suppression isn’t compiler-enforced (or compiler-suggested), but requires careful judgment.

Caller1 carries two unsafe markers by design: the outer one projects the caller contract, the inner one scopes the unsafe operations. Inside an unsafe member, omitting the inner unsafe block at an unsafe operation is a compile error; the signature marker no longer establishes an unsafe context on its own. This outer-propagates / inner-scopes shape matches Rust’s unsafe fn / unsafe { } and Swift’s @unsafe / unsafe expr.

Caller2 is safe-callable, placing no obligation on its callers and requiring no unsafe blocks at their call sites.

The model applies to any caller. The example above demonstrates callers on the same type. The model applies uniformly across types, projects, and packages. It also applies to source generators. There is no planned scoped opt-out mechanism.

The enforcement is compile-time only. The model introduces no new runtime checks and has no performance impact; existing runtime checks that result in exceptions like IndexOutOfRangeException and ArgumentNullException are unchanged.

The .NET runtime libraries will opt in. That’s necessary as the basis of the model for callers. Consuming a library that has opted in does not require your project to opt in, and vice versa. Cross-assembly behavior depends on which side has opted in:

  • Opted-in caller, opted-in callee. The new model. The callee’s unsafe markers travel via metadata, and the caller must wrap calls in an unsafe { } block; without one, the call is a compile error.
  • Opted-in caller, non-opted-in (legacy) callee. Compat mode. The compiler treats any callee member with a pointer type in its signature as unsafe, requiring an enclosing unsafe { } block at the call site. Non-pointer unsafe surface (IntPtr/nint parameters, P/Invoke signatures, and so on) isn’t flagged, because the legacy assembly carries no metadata to distinguish it. Compat mode prevents a “safety dip” where a legacy package’s unsafe APIs would silently lose their pointer-driven unsafe propagation when the new model is enabled.
  • Non-opted-in caller, opted-in callee. No enforcement of the new model’s unsafe markers; the legacy caller can’t interpret them. Legacy C# 1.0 pointer rules still apply: a callee that exposes a pointer type in its signature still requires the legacy caller to be in an unsafe context. The gap is new-model unsafe methods that have no pointer types in their signature (e.g., unsafe byte ReadByte(IntPtr, int)). Those become callable from legacy safe code.

Migration of the runtime libraries is already underway: the reduce-unsafe label tracks the running list of PRs removing unsafe code from the libraries, including swaps like #127394 (replacing MemoryMarshal.Read/Write with BitConverter equivalents) and #127485 (removing unsafe code from IBinaryInteger.TryReadBigEndian). This migration is also a sign that industrial code can be moved to safe patterns. Your unsafe code probably can, too.

To summarize the changes from C# 1.0:

  • unsafe on a member signature now defines a caller-facing contract that propagates unsafety up the call graph. C# 1.0 used it only to establish an unsafe context.
  • An unsafe block is required at every call to an unsafe member.

Cross-language comparison: propagation

The differences between C#, Rust, and Swift are both subtle and instructive. C# 16 propagates unsafety only when the unsafe keyword appears on the member; pointer types and other unsafe-typed parameters do not propagate on their own. Rust behaves the same way: a *const u8 parameter on a plain fn propagates nothing. Swift is the outlier: any @unsafe type appearing in a signature implicitly makes the declaration @unsafe, in addition to the explicit @unsafe attribute.

The implicit Swift model leads to needing @safe as a broadly-applicable opt-out for APIs that encapsulate the unsafety (e.g., Array.withUnsafeBufferPointer). Both C# and Rust include a narrow positive safe form for interop (FFI), but for different reasons. Rust’s safe fn inside an unsafe extern block is an override of the default. The block is unsafe by default and safe opts an individual declaration out, analogous in shape to Swift’s @safe. C# 16’s safe extern for LibraryImport declarations is not an override. It’s a statement about the whole declaration and it’s required because the language biases toward explicit markings and won’t let a developer leave a foreign declaration’s safety implicit.

Every LibraryImport partial method must be marked safe or unsafe:

[LibraryImport("libc")]
internal static safe partial int getpid();

[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)]
internal static unsafe partial nint strlen(byte* str);

getpid has no parameters and returns a primitive; the author attests that the call is sound and safe callers can use it without ceremony. strlen takes a raw pointer the native code will dereference; the author has no way to discharge that obligation at the boundary, so the declaration propagates unsafe and a <safety> block names the caller’s obligation. Omitting both modifiers is a compile error — the developer has to make the choice.

Let’s look at a propagation example. A short Rust program (edition 2024) triggers both an unsafe_op_in_unsafe_fn warning (an unsafe op inside an unsafe fn body without an inner unsafe block) and a hard E0133 error (a call to an unsafe fn from a safe context without an unsafe block):

$ cat main.rs
/// # Safety
///
/// `bytes` must be non-null and point to at least one readable byte.
pub unsafe fn first_byte(bytes: *const u8) -> u8 {
    // No inner `unsafe { }`: warns under `unsafe_op_in_unsafe_fn` (edition 2024).
    *bytes
}

fn main() {
    let data = [42u8];
    // No `unsafe { }` around the call: hard error E0133.
    let value = first_byte(data.as_ptr());
    println!("{value}");
}

$ cargo build
   Compiling unsafe_demo v0.1.0 (/private/tmp/unsafe-demo)
warning[E0133]: dereference of raw pointer is unsafe and requires unsafe block
 --> src/main.rs:6:5
  |
6 |     *bytes
  |     ^^^^^^ dereference of raw pointer
  |
  = note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior
note: an unsafe function restricts its caller, but its body is safe by default
 --> src/main.rs:4:1
  |
4 | pub unsafe fn first_byte(bytes: *const u8) -> u8 {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  = note: for more information, see <https://doc.rust-lang.org/edition-guide/rust-2024/unsafe-op-in-unsafe-fn.html>
  = note: `#[warn(unsafe_op_in_unsafe_fn)]` (part of `#[warn(rust_2024_compatibility)]`) on by default

error[E0133]: call to unsafe function `first_byte` is unsafe and requires unsafe block
  --> src/main.rs:12:17
   |
12 |     let value = first_byte(data.as_ptr());
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
   |
   = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
warning: `unsafe_demo` (bin "unsafe_demo") generated 1 warning
error: could not compile `unsafe_demo` (bin "unsafe_demo") due to 1 previous error; 1 warning emitted

This experience is very similar to what we have planned. The key difference is that both of these cases will be errors in C# 16.

Boiling this all down, C# and Rust code bias toward simple explicit rules and arguably require less domain knowledge. A case-in-point is that it is reasonable to use grep as a safety audit tool with C# 16 and Rust since the explicit keywords act as a fixture that queries can easily grab onto.

Project-level opt-in

The C# 16 safety model has two project-level switches. They are independent and serve different purposes.

The first switch is a new opt-in property (final name landing with the .NET 11 preview). With it off, the legacy C# 1.0 rules continue to govern; with it on, the new caller-unsafe rules apply. This switch decides what counts as unsafe and how it propagates.

The second switch is the existing <AllowUnsafeBlocks> property. It defaults to false (under all versions of C#) and gates every appearance of the unsafe keyword in the project’s source: member signatures, inner blocks, fields, and safe extern declarations under the new rules. Calling an unsafe API from another project counts, because the call site needs an inner unsafe { } block. So a project at the default cannot use any unsafe API.

The two properties combine as follows:

  • New property on, <AllowUnsafeBlocks> off (default). The safest configuration. The project participates in the new model and allows no unsafe code. You know your code isn’t calling Marshal.ReadByte or any other unsafe member.
  • New property on, <AllowUnsafeBlocks> on. The project participates in the new model and allows unsafe code.
  • New property off, <AllowUnsafeBlocks> off. The legacy model continues to apply. The project may not use pointer types.
  • New property off, <AllowUnsafeBlocks> on. The legacy model continues to apply. The project may use pointer types.

We want everyone to move to the new model. We also expect fewer projects to enable <AllowUnsafeBlocks> over time. That’s what we’re doing with our own code.

To help with the move, we plan to ship a dotnet format fixer that performs a best-effort migration on projects that haven’t yet flipped the new property on: wrapping unsafe call sites in unsafe { } blocks, moving the unsafe modifier off types onto their members, and similar mechanical rewrites. The fixer can’t infer safety obligations or write <safety> blocks; that work stays with the developer. It’s a starting point that gets the code compiling under the new rules, not a finished migration.

The core question with agents generating code is whose responsibility it is to determine whether unsafe code has been written. With the new model, that’s the compiler’s. Assuming you haven’t set AllowUnsafeBlocks=true, the compiler will refuse to compile any unsafe code at all. No code review can match the efficiency of a compile error. Memory-safety auditing collapses from inspecting every diff to checking one project property.

Cross-language comparison: defaults

The differences are subtle and important here as well. We can frame the three languages along two safety axes: strict propagation (how aggressively unsafety propagates and what counts as unsafe) and disallowing unsafe code outright. For each axis, the safer posture is either the default or available as an opt-in.

Language Strict propagation Safe code only
C# Opt-in (C# 16 model) Default (AllowUnsafeBlocks=false)
Rust Default (the only model) Opt-in (#![forbid(unsafe_code)])
Swift Opt-in (-strict-memory-safety) Opt-in (no standard switch)

C# 16 will enable the strict model with the new safety keyword. AllowUnsafeBlocks=false remains the default. Under the new model it performs even heavier lifting, because the set of unsafe actions it gates is much larger.

Rust has only one safety model, a strict one. The compiler allows unsafe in any crate by default and requires the #![forbid(unsafe_code)] lint to disable it.

Swift also offers a strict opt-in mode (-strict-memory-safety, SE-0458), which can be set per file or per module to turn implicit unsafety into diagnostics.

These comparisons are not really apples to apples since they are multi-dimensional. Rust has the strongest default position. Our viewpoint aligns with the Memory Safety Continuum: stricter defaults are better. Our intention is to make the new C# safety model the new normal. We’ll start by enabling it with templates. It is simpler for us to introduce a stricter safety model given that unsafe code is already prohibited by default, and we expect good adoption because of that.

Safety documentation

It’s easy to interpret the term “unsafe” literally, but it is misleading. It means “disable the safeties”. Safe code is known by the compiler to comply with a defined safety model, while unsafe code is not. With unsafe code, the burden of knowing falls to the developer. Knowing starts with reading dedicated safety documentation. Properly written unsafe code documents the caller’s obligations: the conditions the caller must satisfy for the code to behave correctly.

Unsafe code with missing or poorly written documentation isn’t safe to call since the caller is left guessing. Code auditors pay close attention to that. That’s already the case in the Rust community: Google and Mozilla.

An analyzer will flag missing /// <safety> blocks.

Rust safety comments

We’ll rely on Rust for canonical examples since it is well-established. Rust uses Safety Comments to demonstrate that unsafe code is sound.

An unsafe Rust function, as_bytes_mut:

/// Converts a mutable string slice to a mutable byte slice.
///
/// # Safety
///
/// The caller must ensure that the content of the slice is valid UTF-8
/// before the borrow ends and the underlying `str` is used.
///
/// Use of a `str` whose contents are not valid UTF-8 is undefined behavior.
///
/// ...
pub unsafe fn as_bytes_mut(&mut self) -> &mut [u8] {
    // SAFETY: the cast from `&str` to `&[u8]` is safe since `str`
    // has the same layout as `&[u8]` (only libstd can make this guarantee).
    // The pointer dereference is safe since it comes from a mutable reference which
    // is guaranteed to be valid for writes.
    unsafe { &mut *(self as *mut str as *mut [u8]) }
}

Clippy enforces this convention. An unsafe function without a # Safety section trips the missing_safety_doc lint:

$ cat main.rs
#![deny(clippy::missing_safety_doc)]

pub unsafe fn first_byte(bytes: *const u8) -> u8 {
    unsafe { *bytes }
}

fn main() {
    let data = [42u8];
    let value = unsafe { first_byte(data.as_ptr()) };
    println!("{value}");
}

$ cargo clippy
    Checking unsafe_demo v0.1.0 (/private/tmp/unsafe-demo)
error: unsafe function's docs are missing a `# Safety` section
 --> src/main.rs:3:1
  |
3 | pub unsafe fn first_byte(bytes: *const u8) -> u8 {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.95.0/index.html#missing_safety_doc
note: the lint level is defined here
 --> src/main.rs:1:9
  |
1 | #![deny(clippy::missing_safety_doc)]
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^

error: could not compile `unsafe_demo` (bin "unsafe_demo") due to 1 previous error

If you are new to Rust, yes, it has /// doc comments. It also has attributes, which are used for proposed safety tags.

The /// # Safety block above the function documents formal and contractual caller obligations. It is the caller’s responsibility to read safety comments. Neglecting to do that can result in writing incorrect unsafe code with undefined consequences. If bad things happen, blame falls to the caller. That’s why we refer to this feature as “caller unsafe”.

The /// comments get copied directly into the public Rust docs for as_bytes_mut. The safety comments are lifted out of the code into a public portal where callers see them. That’s a strong indication of their importance and why they need to be distinct from regular comments.

The example also includes a second, more internal, kind of safety comment. The // SAFETY: notes inside the function body are for developers or auditors of the codebase; they outline safety assumptions, not caller obligations. The compiler doesn’t read, require, or honor these comments. They are a convention.

Both comment styles are important. Together they tell a two-sided story about safety, anchored to the call graph.

With the unsafe block, we’re asserting to Rust that we’ve read the function’s documentation, we understand how to use it properly, and we’ve verified that we’re fulfilling the contract of the function.

Source: Calling an Unsafe Function or Method

This excerpt from the Rust Book makes clear that safety depends on a process that starts with compiler diagnostics but doesn’t end there. The corresponding Rust lint (unsafe_op_in_unsafe_fn) was allow by default in earlier editions, so missing inner unsafe blocks were silently accepted. The 2024 edition promoted it to warn-by-default, a compatibility compromise that keeps existing crates building across the edition boundary. C# 16 doesn’t carry the same legacy and makes it a compile error.

C# safety comments

C# uses two safety comment styles, shown here in the ReadByte mockup:

/// <summary>Reads a single byte from unmanaged memory.</summary>
/// <safety>
/// The sum of <paramref name="ptr"/> and <paramref name="ofs"/> must address a byte
/// the caller is permitted to read.
/// </safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs)
{
    try
    {
        byte* addr = (byte*)ptr;
        unsafe
        {
            // SAFETY: relies on caller obligation.
            return addr[ofs];
        }
    }
    catch (NullReferenceException)
    {
        throw new AccessViolationException();
    }
}

The /// <safety> block above the signature is the formal caller contract. The // SAFETY: comment inside the body is an internal note naming what the unsafe operation relies on.

The signature alone, unsafe byte ReadByte(IntPtr, int), tells you the shape, not the safety contract. The /// <safety> block is the contract, which is why an analyzer will flag its absence. The lesson is that knowing the shape of an unsafe API is necessary but not sufficient to write correct code. Writing unsafe code calls for safety glasses.

A single residual obligation is named: ptr + ofs must address a readable byte. The caller must discharge it. The unsafe keyword on the signature is what surfaces that obligation to callers. The // SAFETY: comment names what the dereference is relying on: that the caller has safety guards for the obligation.

Consider the states an IntPtr parameter can be in when a caller passes it:

  • IntPtr.Zero (null): The dereference traps on the runtime’s null-check guard pages and surfaces as a NullReferenceException, which the catch translates to AccessViolationException. Removing the catch wouldn’t change safety, only the type of exception.
  • A pointer to unmapped memory (uninitialized, freed, or a garbage value): The dereference takes a hardware access violation. On most platforms this terminates the process; the catch may not even run.
  • A pointer to mapped memory the caller doesn’t own (someone else’s buffer, the GC heap, a code segment): The dereference may succeed. Mapped pages can still be unreadable (guard pages, for example), in which case behavior matches the previous bullet. When it does succeed, ReadByte returns an arbitrary byte from memory with an arbitrary value. No exception, no warning. This is the textbook UB outcome; the program continues with corrupted assumptions. Worst case is that it reads memory that is interpreted as a valid value for the program.
  • A pointer the caller correctly knows points to a readable byte: Works as intended.

The try/catch handles the first state, fails ungracefully on the second, and is invisible to the third. None of that is validation. The contract travels up to the caller, where information about the buffer’s origin, length, and lifetime can be used to rule out the dangerous states. The /// <safety> block is what makes that contract visible. The caller needs to understand and protect against these cases.

Safety guards

Documentation names the obligations. Guards discharge them. This pattern matters most at the unsafe boundary, where a developer attests that unsafe code has been brought into alignment with compiler-provided safety. The boundary is also where a review should start. With good documentation as a guide, the reviewer can tell whether the code is compliant.

One might wonder why unsafe methods don’t include enough if checks to remove the need for caller obligations. For ReadByte, no if check inside the method can validate that a caller-supplied IntPtr points to readable memory: the runtime simply doesn’t know what the caller has allocated, where, or for how long. Callers are uniquely able to determine the minimum set of checks that maintain safety while maximizing performance.

Note: there isn’t a standard name for these boundary methods/functions. Rust docs call them “safe elements”. This post calls them “unsafe boundary methods”: methods that sit at the boundary of safe and unsafe code, where unsafety is suppressed. The label unsafe is deliberate: these methods retain every dangerous capability of unsafe-decorated methods; they just don’t propagate that to their callers.

Rust safety guards

Another Rust example, str.split_at:

pub fn split_at(&self, mid: usize) -> (&str, &str) {
    // is_char_boundary checks that the index is in [0, .len()]
    if self.is_char_boundary(mid) {
        // SAFETY: just checked that `mid` is on a char boundary.
        unsafe { (self.get_unchecked(0..mid), self.get_unchecked(mid..self.len())) }
    } else {
        slice_error_fail(self, 0, mid)
    }
}

Unsafe boundary functions typically have only // SAFETY: comments; they don’t impose obligations of their own. The formal /// style is reserved for unsafe methods, whose obligations the boundary then discharges. Functions that propagate must be marked unsafe.

The if self.is_char_boundary(mid) check in split_at is a guard that maintains safety for the unsafe code it calls. It ensures that the split is on a character boundary, since Unicode characters can be multi-byte. If that test fails, then the program panics via slice_error_fail. A panic will crash the program to prevent undefined behavior.

A program that panics to avoid undefined behavior is far more reliable than one that lets it happen.

C# safety guards

The same boundary pattern from Rust applies in C#: same // SAFETY: convention, same absence of an unsafe marker on the signature.

String.CopyTo:

// Converts a substring of this string to an array of characters. Copies the
// characters of this string beginning at position sourceIndex and ending at
// sourceIndex + count - 1 to the character array buffer, beginning
// at destinationIndex.
//
public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count)
{
    ArgumentNullException.ThrowIfNull(destination);

    ArgumentOutOfRangeException.ThrowIfNegative(count);
    ArgumentOutOfRangeException.ThrowIfNegative(sourceIndex);
    ArgumentOutOfRangeException.ThrowIfGreaterThan(count, Length - sourceIndex, nameof(sourceIndex));
    ArgumentOutOfRangeException.ThrowIfGreaterThan(destinationIndex, destination.Length - count);
    ArgumentOutOfRangeException.ThrowIfNegative(destinationIndex);

    unsafe
    {
        // SAFETY: the bounds checks above ensure that `count` characters
        // starting at `sourceIndex` are in range of this string, and that
        // `count` characters starting at `destinationIndex` fit in `destination`.
        Buffer.Memmove(
            destination: ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(destination), destinationIndex),
            source: ref Unsafe.Add(ref _firstChar, sourceIndex),
            elementCount: (uint)count);
    }
}

Every ThrowIf* call here is a memory-safety guard. Each one props up an invariant that the raw Buffer.Memmove call assumes:

  • ThrowIfNull(destination): without it, MemoryMarshal.GetArrayDataReference(null) is UB.
  • ThrowIfNegative(count): without it, (uint)count silently wraps a negative value into a huge elementCount, and the resulting out-of-range copy is UB.
  • ThrowIfNegative(sourceIndex) and ThrowIfNegative(destinationIndex): without them, Unsafe.Add(ref …, negativeIndex) walks the ref off the front of the storage, and the resulting read or write is UB.
  • The two ThrowIfGreaterThan checks layer on top of the negative checks above (and rely on the runtime invariant that Length is in [0, int.MaxValue], so that Length - sourceIndex doesn’t overflow) to bound count against the remaining capacity of source and destination. Without them, the copy can run past the end of either buffer, and the resulting read or write is UB.

The checks compose. Each one is only sufficient because the preceding ones have already ruled out classes of inputs. Change any link in that chain (switch to an unsigned index type, or change what the runtime guarantees about Length), and the safety reasoning has to be re-derived.

The ThrowIf* methods are the C# analog of Rust panic helpers like slice_error_fail; both crash the program at the boundary rather than let UB happen, and both are factored into separate functions to keep cold paths out of hot code.

Unsafe fields

Fields deserve a discussion. A field needs to be unsafe when its declared type doesn’t express an invariant the enclosing type maintains and downstream code depends on. The unsafety lives in the gap between what the type system sees and what the enclosing type promises.

The simplest case is a field holding a native pointer. The example below is a mockup; it isn’t sourced from dotnet/runtime like the other examples.

public class NativeBuffer : IDisposable
{
    /// <safety>
    /// Must be null or point to a buffer of Length bytes.
    /// </safety>
    private unsafe byte* _ptr;
    public int Length { get; }

    public NativeBuffer(int length)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(length);
        unsafe
        {
            // SAFETY: NativeMemory.Alloc throws OutOfMemoryException on failure rather than
            // returning null (unlike the malloc it wraps), so on return _ptr points to `length` bytes.
            _ptr = (byte*)NativeMemory.Alloc((nuint)length);
        }
        Length = length;
    }

    public byte ReadAt(int index)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(index);
        ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Length);
        unsafe
        {
            ObjectDisposedException.ThrowIf(_ptr is null, this);
            // SAFETY: bounds checked above; null check just above; _ptr therefore points to Length bytes
            return _ptr[index];
        }
    }

    public void Dispose()
    {
        unsafe
        {
            // SAFETY: _ptr is null or was returned by NativeMemory.Alloc; Free accepts both
            NativeMemory.Free(_ptr);
            _ptr = null;
        }
    }
}

The class is safe-callable, with the unsafe field carrying the validity invariant on behalf of the public surface. Length is a get-only auto-property fixed at construction; its immutability is the other half of the invariant, since _ptr‘s size obligation is stated in terms of Length. If Length could change after construction, it would need its own unsafe marker and <safety> block to keep the pair coherent. Dispose deliberately weakens that invariant from “valid” to “null or valid” by writing null, which is why _ptr can’t be readonly and why ReadAt checks for null before dereferencing. The unsafe marker on the field keeps both writes (the allocation in the constructor and the invalidation in Dispose) reviewable in one place.

A more idiomatic case in the runtime libraries is a field whose declared type is sound but less specific than what the class actually maintains. The design doc gives a simplified version of this pattern: a generic class holds an Array field that must always contain a T[]. Array is the object of array types; every T[] is an Array, so declaring the field as Array is type-correct, and doing so avoids generic specialization costs. The C# type system permits any array to be assigned to that field, while the class promises always exactly T[]. The unsafety lives in that gap: the type system can’t see the tighter invariant, and the class is responsible for upholding it.

public class ArrayWrapper<T>
{
    /// <safety>
    /// Must always hold a value whose runtime type is T[].
    /// </safety>
    private readonly unsafe Array _array;

    public ArrayWrapper(T[] items)
    {
        ArgumentNullException.ThrowIfNull(items);
        unsafe
        {
            // SAFETY: items is statically T[], so the field invariant holds.
            _array = items;
        }
    }

    public T GetItem(int index)
    {
        unsafe
        {
            // SAFETY: _array is always a T[] per the field's <safety> block
            var typedArray = Unsafe.As<T[]>(_array);
            return typedArray[index];
        }
    }
}

The pattern is the same as NativeBuffer: an unsafe field with a documented invariant, unsafe blocks at the boundary discharging it, and a safe-callable public surface.

Rust is working through the same problem, and the unsafe-fields proposal uses Vec<T> as its motivating case. Vec<T> carries an invariant that the elements at data[i] for i < len are initialized. Today, that invariant lives only in comments and prose. There is nothing stopping a method (even a private one) from desynchronizing len and data in entirely safe code:

pub struct Vec<T> {
    data: Box<[MaybeUninit<T>]>,
    len: usize,
}

impl<T> Vec<T> {
    // Safe code, but the next read is undefined behavior.
    pub fn evil(&mut self) {
        self.len += 2;
    }
}

The proposed future shape moves the invariant into the type system by marking both fields unsafe:

struct Vec<T> {
    // SAFETY: The elements `data[i]` for
    // `i < len` are in a valid state.
    unsafe data: Box<[MaybeUninit<T>]>,
    unsafe len: usize,
}

With that change, any write to len or data has to happen inside an unsafe block; evil no longer compiles as written. The two fields are reviewed together, in the same place, against the same contract. That’s the same benefit NativeBuffer gets from pairing unsafe byte* _ptr with a fixed Length, and that ArrayWrapper<T> gets from pairing readonly unsafe Array _array with the always-T[] promise.

You might say that “you can still write evil with unsafe and it still results in UB”. Yes. The entire proposition is that unsafe code is marked and easy to audit. That’s the basis of safety in all of these languages.

A few rules of thumb for unsafe on fields:

  • Writes are the primary motivation. unsafe on the field forces every write into a reviewable context where the contract is in view, establishing (at least) the member-to-member discipline that keeps the invariant intact. For example, a write to _ptr in the NativeBuffer example would violate Length.
  • Readonly fields satisfy much of the same need. It helps to think of unsafe readonly as the contract plus a built-in guard: unsafe names the invariant, and readonly is the safety guard that prevents post-construction writes from violating it. Drop the readonly and the contract remains; it just has to be discharged the harder way, by reviewing every write site. The ArrayWrapper<T> example above is readonly unsafe for exactly this reason. Rust is converging on the same shape via the unsafe-fields design axioms: the marker stays, but the operations it gates (writes, reinitialization) are exactly the ones immutability already prevents.
  • Private isn’t a free pass. It’s tempting to assume that because a field is private, the type’s own methods can be trusted to maintain the invariant. That was the old unsafe type model. In the new model, member-to-member interaction is itself a contract surface; one method’s correct write can be undone by another method’s uncoordinated write. Unsafety is about protecting the contract from any code that might violate it, including code within the type itself.

A migration walkthrough

The best way to understand the model is to migrate some existing code to it. This is what the .NET team is doing across the runtime libraries. Pick an unsafe API, follow it to a caller, and decide whether the migration can discharge the callee’s obligations inline or has to propagate them upward. Each caller is a candidate place for the boundary; the migration answers whether that’s where the boundary belongs.

This section is speculative. The model isn’t finalized and the runtime libraries haven’t been migrated yet. The examples are informed guesses, intended to convey where we’re headed and what the new model implies for existing code.

We’re going to migrate some methods in this section that bottom out in NativeMemory.Alloc and NativeMemory.Free. Here’s how the two NativeMemory methods look under the new model:

public static void* Alloc(nuint byteCount);

/// <safety>
/// The caller must ensure:
///
/// - <paramref name="ptr"/> was returned by <see cref="Alloc(nuint)"/> (or a
///   compatible allocator) and has not already been freed.
/// - No live pointer or span aliases the storage at the time of this call.
/// </safety>
public static unsafe void Free(void* ptr);

The asymmetry is intentional. Alloc becomes safe. It returns a void*, but holding a pointer isn’t unsafe on its own; the unsafety is in the eventual dereference, which the caller wraps. Failing to free is a leak, not a safety issue. (Alloc also differs from malloc in that it throws OutOfMemoryException on failure rather than returning null, so callers don’t have to guard the return.) Free remains unsafe because it carries real preconditions: the pointer must be one a compatible allocator returned and not already freed, and nothing else can alias the storage. The <safety> block makes those obligations visible where every caller and reviewer can see them.

We’ll now jump to a caller. Here’s a mockup of FileVersionInfo‘s constructor under the new model. The constructor parses a native version-info blob into this object’s string and integer fields (_companyName, _fileVersion, _fileMajor, and so on); the allocation is just the scratch buffer that holds the blob while GetVersionInfoForCodePage reads from it.

Current signature: private unsafe FileVersionInfo(string fileName). It is unsafe solely to establish an unsafe context.

Here’s the updated signature and implementation, with internal safety comments.

private FileVersionInfo(string fileName)
{
    _fileName = fileName;

    uint infoSize = Interop.Version.GetFileVersionInfoSizeEx(
        Interop.Version.FileVersionInfoType.FILE_VER_GET_LOCALISED, _fileName, out _);
    if (infoSize != 0)
    {
        unsafe
        {
            // SAFETY:
            // - bounds: `infoSize` is the size returned by GetFileVersionInfoSizeEx
            //   and is the same value passed to both Alloc and GetFileVersionInfoEx,
            //   so all reads through `memPtr` stay within the allocated range.
            // - lifetime: `memPtr` is freed in the finally before this constructor
            //   returns, and never escapes; every consumer (GetLanguageAndCodePage,
            //   GetVersionInfoForCodePage) is called from within this method and
            //   writes its results into this object's fields.
            void* memPtr = NativeMemory.Alloc(infoSize);
            try
            {
                if (Interop.Version.GetFileVersionInfoEx(
                    /* flags */ default, _fileName, 0U, infoSize, memPtr))
                {
                    uint lcp = GetLanguageAndCodePage(memPtr);
                    _ = GetVersionInfoForCodePage(memPtr, lcp.ToString("X8")) ||
                        (lcp != 0x040904B0 && GetVersionInfoForCodePage(memPtr, "040904B0")) ||
                        (lcp != 0x040904E4 && GetVersionInfoForCodePage(memPtr, "040904E4")) ||
                        (lcp != 0x04090000 && GetVersionInfoForCodePage(memPtr, "04090000"));
                }
            }
            finally
            {
                NativeMemory.Free(memPtr);
            }
        }
    }
}

The constructor is a sound unsafe boundary. The remaining unsafety (the interop calls that read through memPtr, and the Free at the end) is discharged inline:

  • Bounds: a single infoSize value flows from the size-query call into Alloc and into every interop call that reads through memPtr; the three uses are tied together by name, so reads stay within the allocated range.
  • Lifetime: the try/finally guarantees Free runs before the constructor returns, even on an exception from the interop calls. The pointer never escapes; every helper that consumes it is called inside this method, so no alias survives past Free.

No unsafe marker on the constructor, no <safety> block; the unsafety is fully sealed inside the body. Unsafe constructors are possible in the new model (they propagate the obligation to whatever code instantiates the type), but this one doesn’t need to be unsafe.

Now a mockup of FixedMemoryKeyBox:

internal sealed class FixedMemoryKeyBox : SafeHandle
{
    /// <safety>
    /// Must equal the byte size of the allocation pointed to by <c>handle</c>.
    /// </safety>
    private readonly unsafe int _length;

    internal FixedMemoryKeyBox(ReadOnlySpan<byte> key) : base(IntPtr.Zero, ownsHandle: true)
    {
        void* memory;
        unsafe
        {
            // SAFETY:
            // - alloc:   NativeMemory.Alloc returns a pointer to key.Length writable bytes.
            // - span:    new Span<byte>(memory, key.Length) addresses exactly those bytes.
            // - lifetime: ownership of the pointer transfers to this SafeHandle
            //             via SetHandle below; ReleaseHandle frees the allocation when
            //             the ref-count reaches zero.
            // - _length: paired with the allocation made on this line.
            memory = NativeMemory.Alloc((nuint)key.Length);
            key.CopyTo(new Span<byte>(memory, key.Length));
            _length = key.Length;
        }
        SetHandle((IntPtr)memory);
    }

    /// <safety>
    /// The returned span aliases storage owned by this SafeHandle.
    /// The caller must ensure:
    ///
    /// - the span is not used after this SafeHandle is disposed;
    /// - access is bracketed by <see cref="SafeHandle.DangerousAddRef"/> and
    ///   <see cref="SafeHandle.DangerousRelease"/> (or equivalent), so
    ///   disposal on another thread can't free the buffer mid-use.
    /// </safety>
    internal unsafe ReadOnlySpan<byte> DangerousKeySpan
    {
        get
        {
            unsafe
            {
                // SAFETY:
                // - bounds: `_length` matches the allocation made in the ctor.
                // - lifetime: NOT discharged here; propagated to the caller
                //   via the <safety> block above. The `Dangerous` prefix
                //   echoes that contract in the API name.
                return new ReadOnlySpan<byte>((void*)handle, _length);
            }
        }
    }

    internal TRet UseKey<TState, TRet>(TState state, Func<TState, ReadOnlySpan<byte>, TRet> func)
    {
        bool addedRef = false;
        unsafe
        {
            // SAFETY: AddRef holds the SafeHandle alive for the duration of
            // the callback, so `DangerousKeySpan` aliases live storage. The
            // span is not retained beyond `func`'s return.
            try
            {
                DangerousAddRef(ref addedRef);
                return func(state, DangerousKeySpan);
            }
            finally
            {
                if (addedRef)
                {
                    DangerousRelease();
                }
            }
        }
    }

    protected override bool ReleaseHandle()
    {
        unsafe
        {
            // SAFETY: SafeHandle's ref-counting guarantees no live span
            // aliases `handle` at this point; the new Span<byte>(handle, _length)
            // addresses the allocation made in the ctor.
            CryptographicOperations.ZeroMemory(new Span<byte>((void*)handle, _length));
            NativeMemory.Free((void*)handle);
        }
        return true;
    }

    public override bool IsInvalid => handle == IntPtr.Zero;
}

FixedMemoryKeyBox is two boundaries in one type, illustrating both directions:

  • DangerousKeySpan is caller-unsafe. Bounds is discharged inline by the _length field invariant. The int is safe on its own; the safety issue is its coupling to handle: they are a pair that have to match. Lifetime is not discharged. The span aliases storage owned by the SafeHandle, and the storage outlives the property call by design. The <safety> block names two residual obligations: don’t outlive the SafeHandle, and bracket access with DangerousAddRef/Release. The compiler can’t enforce either. The marker tells callers they have work to do.
  • UseKey is the sound boundary built on top. It discharges the lifetime obligation by bracketing the callback with DangerousAddRef/Release in a try/finally. The ReadOnlySpan<byte> passed to func is safe by virtue of ref struct lifetime rules. From the outside, UseKey is safe-callable; the unsafety is sealed inside the bracket.

Binary distribution

.NET libraries are often distributed as binaries. A popular library published to nuget.org might have zero or a thousand warnings, but you know it has zero errors. Errors are one of the few aspects of compilation that are reliably communicated between producer and consumer.

C# 16 unsafe relies heavily on new compiler errors. Opting in to the new model means that the annotation work has been done. It will be straightforward to inspect a project and see whether it uses unsafe code at all.

Swift, for example, relies more heavily on warnings for memory safety adoption. The burden for Swift is much lower since dependencies are distributed as source. You can see errors and warnings of dependencies with equal fidelity when you build. Rust also has source-distributed dependencies, but relies heavily on errors.

We are considering adding badges on nuget.org to encourage adoption of the new memory-safety enforcement and to make it easier to find libraries that have done so. Libraries and packages that have adopted the model will be stamped accordingly, making it easy to inspect and understand the safety status of your supply chain (as it relates to what the compiler sees).

It will be common for projects compiled with the old model to consume packages built with the new one, and vice versa. As described in the nutshell section, the two directions are asymmetric. An opted-in project enforces compat-mode rules against legacy packages: any pointer type in a callee signature requires an enclosing unsafe { } block at the call site. A legacy project, by contrast, sees opted-in packages as ordinary assemblies and is not subject to any new diagnostics. The asymmetry is deliberate. The opted-in side carries the safety guarantee, and compat mode keeps that guarantee from quietly degrading when consuming legacy code.

Remaining design space

The intent of our project is to deploy all aspects of the new model at once, both because it is only coherent as a whole, and to avoid developers needing to adopt a progressive set of breaking changes. However, there are a couple of design aspects that we could not address in C# 16.

The first is reflection, which is a carve-out in the model. Code can call unsafe APIs through MethodInfo.Invoke without an enclosing unsafe block, and reflection writes can violate the invariants documented on unsafe fields. Reflection-heavy code should be reviewed for unsafe API calls and for writes that bypass the contracts the new model expresses. We may address reflection usage in a later version.

The second is lifetimes. Rust addresses lifetime through its borrow checker; we are not planning a pervasive system like borrowing. C# relies on a GC and ref-based ownership to cover some of the same territory. We are considering a targeted ownership model, as mentioned in Memory Safety in .NET. We’ll post design plans around that later.

The primary use case for stronger lifetime enforcement is ArrayPool, particularly for the Rent and Return methods. The key scenario is returning an array and continuing to use it. That’s a “use after free” violation. It is easy to get the convention wrong, and we’ve made that mistake in our own code. In contrast, Rent with no Return is a leak and not a memory safety violation.

Analogies

The Caller-unsafe feature invites analogies. Most fall down on close inspection.

Claim — Nullable is similar to caller-unsafe. Nullable reference types require method inspection and potential signature updates to correctly participate in the model. They also push nullability from callee to caller and include a suppression mechanism.

Reality. Nullable reference types are a use-site concern that affects the type of an expression. They don’t affect the nature of the caller at all. Nullable suppression (!) operates on a single expression; it tells the compiler the value is non-null at that point. With unsafe, there’s no expression-level shortcut; every call to an unsafe member requires an enclosing unsafe { } block. Suppression in the unsafe model is a scoped, reviewable region, not a per-value annotation.

Claim — Async is similar to caller-unsafe. Async propagates from method to method. The async keyword forces the propagation, just like unsafe. Task.Wait() is the suppression mechanism.

Reality. The async keyword is more similar to C# 1.0 unsafe in that it establishes an async context for the method in which await can be used; the method’s return type (Task/ValueTask) is what callers see. The propagation mechanism is an awaitable return type: the type system itself. Task.Wait() forces a transition from async to sync, which comes with significant trade-offs. It’s not a formal suppression mechanism.

Claim — Swift’s type-level @unsafe is just C# 1.0’s type-level unsafe. Both languages use the keyword on a type, so the C# 16 removal of type-level unsafe looks like it’s giving up something Swift kept.

Reality. The two markers share a keyword and a target but are nearly orthogonal in semantics. Swift’s @unsafe on a type is a caller-facing contract: any declaration that uses the type becomes implicitly @unsafe, and callers have to wrap accesses in unsafe expressions. C# 1.0’s unsafe on a type was an implementation scope: it let the type’s member bodies use pointers but didn’t propagate anything to callers. C# 16 removes the C# 1.0 form because it carried no caller information. The two also differ in disposition: Swift’s marker is non-permissive, adding an obligation on callers, while C# 1.0’s marker was permissive, unlocking capabilities inside the type’s members. The Swift form is the more safety-first of the two. Reading Swift’s type-level marker through a C# 1.0 lens is the wrong lens.

AI enablement

The model adds two things an agent can’t ignore: a call graph partitioned into safe, unsafe, and boundary methods; and a compiler that rejects unsafe calls without an enclosing unsafe block. An analyzer will also contribute warnings for missing <safety> docs. Each of these narrows the code an agent can generate while keeping the build happy, particularly if TreatWarningsAsErrors is set. An agent generating code against MemoryMarshal.ReadByte has to either propagate unsafe upward to its caller or suppress it with guards at the boundary.

<safety> docs act as per-API instructions. Even with that, a code-generating agent can and sometimes will miss a guard, and the compiler won’t notice. The informative boundary still helps: it tells a human or code-review agent exactly where the guards must live and what they should protect. The same dynamic applies to nullable reference types and AOT analyzers: tighter grammars narrow the search space, and model output tracks accordingly.

There are two key ways that agents can subvert the model:

  • Generate code that doesn’t compile.
  • Switch the project back to the old model and/or enable AllowUnsafeBlocks. This is similar to when agents sometimes want to disable TreatWarningsAsErrors or IsAotCompatible.

Both categories are easy to detect in code review or identify in git history. “Easier to detect in code review” is the tagline for the entire initiative.

The migration to the new model is also a good fit for agents. Migrating existing code to the model and writing new code within the model isn’t really a different activity. Once the first set of .NET runtime library APIs are migrated, conformance becomes a uniform task for old and new code.

Patterns well-established in Rust (unsafe fn, unsafe {}) map cleanly onto C#-shaped code. Agents can pattern-match on the existing corpora (Rust’s std, Swift’s standard library) and on the .NET runtime libraries as they migrate. Arguably, the highest-value pattern match is on the structure and idiom of safety documentation. That aspect of the migration would be the most difficult to skill-ify. As noted earlier, safety documentation is the most critical aspect of the new model.

Adjacent research comes to the same conclusions:

  • CRUST-Bench: A Comprehensive Benchmark for C-to-safe-Rust Transpilation (Khatry et al., COLM ’25) found that agents with compiler feedback roughly double the success rate of single-shot generation on C-to-safe-Rust repository translation.
  • Gorilla: Large Language Model Connected with Massive APIs (Patil et al., NeurIPS ’24) showed that LLMs given retrievable API documentation call APIs more reliably and hallucinate less than unaided baselines.
  • Do Users Write More Insecure Code with AI Assistants? (Perry et al., CCS ’23) found that developers using AI coding assistants produced significantly less secure code than an unassisted control group, while rating their own output as more secure. That’s the gap language-level safety enforcement is meant to close.
  • Type-Constrained Code Generation with Language Models (Mündler et al., PLDI ’25) showed that pushing type-system constraints into LLM decoding (rather than relying on post-hoc compiler feedback) cuts compilation errors by more than half and improves functional correctness across synthesis, translation, and repair. Richer language rules shape generation, not just validate it.
  • MultiPL-E: A Scalable and Extensible Approach to Benchmarking Neural Code Generation (Cassano et al., TSE ’23) showed that LLM code-generation performance tracks syntactic proximity to high-resource languages, not just training volume in the target language. That’s the lever that lets Rust’s unsafe fn / unsafe {} corpus carry over to C#-shaped code.
  • LLM Assistance for Memory Safety (Rastogi et al., ICSE ’25) tackled the migration version of this problem: inferring the source-level annotations needed to retrofit legacy C onto the Checked C safe dialect. Their tool inferred 86% of the annotations symbolic tools couldn’t, on real codebases up to 20K LOC. The same shape of work (naming the obligations existing code already implicitly relies on) is what migrating to the new C# model will require.

Closing

The new model layers a set of (opt-in) breaking changes onto code that uses unsafe today: unsafe on a member signature defines a caller-facing contract, an unsafe block is required at every call to an unsafe member, and every unsafe member should carry a /// <safety> block. Three smaller deltas round out the model: the unsafe type modifier becomes an error, the new safe keyword marks extern declarations whose safety the compiler can’t classify on its own, and pointer types in signatures no longer propagate unsafety on their own.

We envision a future where C# is among a set of languages chosen and noted for their type- and memory-safety enforcement. With this model change, C#, Rust, and Swift have a more common safety vocabulary and workflow. We imagine teams adopting a complete supply-chain view of their dependencies, whether C# all the way down or C# at the app layer over Rust at the system layer. Our own team has moved large blocks of C++ to C# over the years for exactly this reason: safe C# doesn’t carry a memory-safety review burden.

Once a team moves a subset of a codebase to the new safety model, there will likely be increased motivation to move all of it and its dependencies. This may be easier than it seems for many dev shops. The new model maintains C# largely as-is and tweaks the unsafe patterns that most developers do not touch, while significantly improving the overall safety capability and posture of the language. We believe that this feature is among the highest-leverage changes that we can make to improve developer confidence in this new era of coding.

This project benefits from contributions from: Andy Gocke, Egor Bogatov, Fred Silberberg, Jan Jones, Jan Kotas, Julien Couvreur, Mads Torgersen, Rich Lander, Tanner Gooding, and others.

The post Improving C# Memory Safety appeared first on .NET Blog.

Read the whole story
alvinashcraft
15 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

The Cost of NOT Implementing Financial-Grade Security

1 Share

Bearer tokens are simple. PKCE is easy to skip. Pushed Authorization Requests feel like overhead. Everything works fine, right up until it doesn't. And when it doesn't, the costs aren't measured in engineering hours. They're measured in regulatory fines, breach notifications, and headlines that make customers look for alternatives.

Most teams evaluate security upgrades by asking what they cost. The better question is what it costs to skip them.

Read the whole story
alvinashcraft
39 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Introducing the pkg.go.dev API

1 Share

The Go Blog

Introducing the pkg.go.dev API

Ethan Lee, Hana Kim, and Jonathan Amsterdam
21 May 2026

Since its inception, pkg.go.dev has established itself as the Go community’s primary resource for package documentation and discovery. While we initially prioritized creating a comprehensive and highly accessible web interface for users, the need for programmatic access has become increasingly clear. Developers building tools, IDE integrations, and automated workflows have historically relied on fragile workarounds like web scraping to access this data. To better address these evolving requirements, we are now expanding our platform to provide robust, direct access to the information our community needs.

Today, we are excited to introduce the official pkg.go.dev API — a service interface for querying metadata about published Go modules. This launch is a direct response to years of community feedback. Furthermore, the need for a formalized interface has become even more acute with the rise of AI-assisted coding. Tools can now access the specific, high-fidelity context needed to reason about the Go ecosystem with greater precision.

The service interface

Built for stability and efficient caching, the API uses a stateless, GET-only architecture. Primary endpoints are currently hosted under the /v1beta path. Following a period of community feedback and confirmed stability, we intend to transition toward a formal v1 release.

For a complete interactive reference of all endpoints, query parameters, and response shapes, see the pkg.go.dev/api specification. The machine-readable API contract is also published directly as an OpenAPI specification.

Core endpoints

Endpoint Description
/v1beta/package/{path} Information about the package at {path}.
/v1beta/module/{path} Information about the module at {path}.
/v1beta/versions/{path} Versions of the module at {path}.
/v1beta/packages/{path} Information about packages of the module at {path}.
/v1beta/search?q={query} Search results for a given query.
/v1beta/symbols/{path} List of symbols declared by the package at {path}.
/v1beta/imported-by/{path} Paths of packages importing the package at {path}.
/v1beta/vulns/{path} Vulnerabilities of the module or package at {path}.

One design principle for this API is “precision over convenience.” For context, when go mod tidy encounters an import of a package that isn’t provided by an existing dependency of the main module, it applies the “longest module path” rule to determine which module is needed. (The fact that two or more modules could provide the package is what makes it possible to later carve out a submodule without breaking existing programs.) The pkg.go.dev web interface follows a similar convention when choosing which package to display for a given package path. By contrast, the pkg.go.dev API requires the module to be specified unambiguously. If a package path is ambiguous because it exists in multiple modules, the API returns a list of candidates and reports an error asking the client to be more specific.

For example, a package imported as example.com/a/b/c could be provided by module example.com/a or by example.com/a/b. While the pkg.go.dev web interface will automatically resolve the “longest module path” (example.com/a/b), a client querying the API must specify the module explicitly to avoid an ambiguous resolution error.

Specifying versions

For endpoints that retrieve package, module, or symbol information, you can specify the desired version using the optional version query parameter. The API returns information about the latest version of the module or package by default. The parameter supports:

  • Semantic Versions: Retrieve data for a specific release tag (e.g., ?version=v1.2.3 or ?version=v0.6.0).
  • Branch Names: Reference default development branches—specifically master or main (e.g., ?version=master). The API will automatically resolve the branch to its corresponding pseudo-version. Note that custom or arbitrary branch names are not supported.

If the version parameter is omitted, the API defaults to resolving the request against the latest tagged version of the package or module.

Example: raw API request

To retrieve structured metadata for a specific package directly (using jq for formatting):

$ curl https://pkg.go.dev/v1beta/package/github.com/google/go-cmp/cmp | jq .
{
  "modulePath": "github.com/google/go-cmp",
  "version": "v0.7.0",
  "isLatest": true,
  "isStandardLibrary": false,
  "goos": "all",
  "goarch": "all",
  "path": "github.com/google/go-cmp/cmp",
  "name": "cmp",
  "synopsis": "Package cmp determines equality of values.",
  "isRedistributable": true
}

To query a specific branch version (like master) and see it resolve automatically to its corresponding pseudo-version:

$ curl -s "https://pkg.go.dev/v1beta/package/github.com/google/go-cmp/cmp?version=master" | jq '{path, version}'
{
  "path": "github.com/google/go-cmp/cmp",
  "version": "v0.7.1-0.20260310220054-34c9473539b8"
}

The pkgsite-cli reference implementation

To demonstrate how to interact with our API, we are providing a reference client implementation: pkgsite-cli. This implementation serves as a practical example for developers looking to build their own integrations, showing how to handle the data directly from the terminal. Please be aware that as the API continues to evolve, the interface and behavior of this command may change.

To get started, install the command:

$ go install golang.org/x/pkgsite/cmd/internal/pkgsite-cli@latest

To search for packages:

$ pkgsite-cli search "uuid"
github.com/google/uuid
  Module:   github.com/google/uuid@v1.6.0
  Synopsis: Package uuid generates and inspects UUIDs.
... more

To inspect a specific package:

$ pkgsite-cli package github.com/google/go-cmp/cmp
github.com/google/go-cmp/cmp
  Name:      cmp
  Module:    github.com/google/go-cmp
  Version:   v0.7.0 (latest)
  Synopsis:  Package cmp determines equality of values.

To see which packages import a specific package:

$ pkgsite-cli package --imported-by github.com/google/go-cmp/cmp
github.com/google/go-cmp/cmp
  Name:     cmp
  Module:   github.com/google/go-cmp
  Version:  v0.7.0 (latest)
  Synopsis: Package cmp determines equality of values.

Imported by:
  cloud.google.com/go/internal/testutil
  cuelang.org/go/internal/cuetxtar
  chainguard.dev/apko/pkg/build/types
  ... more

To list symbols declared by a package:

$ pkgsite-cli package --symbols github.com/google/go-cmp/cmp
github.com/google/go-cmp/cmp
  Name:     cmp
  Module:   github.com/google/go-cmp
  Version:  v0.7.0 (latest)
  Synopsis: Package cmp determines equality of values.

Symbols:
  type Indirect struct{}
  type MapIndex struct{}
  type Option interface{}
  ... more

To list versions of a module:

$ pkgsite-cli module -versions github.com/google/go-cmp
github.com/google/go-cmp
  Version:          v0.7.0 (latest)
  Repository:       https://github.com/google/go-cmp
  Has go.mod:       yes
  Redistributable:  yes

Versions:
  v0.7.0
  v0.6.0
  v0.5.9
  ... more

To list both versions and packages of a module:

$ pkgsite-cli module -packages -versions github.com/google/go-cmp
github.com/google/go-cmp
  Version:          v0.7.0 (latest)
  Repository:       https://github.com/google/go-cmp
  Has go.mod:       yes
  Redistributable:  yes

Versions:
  v0.7.0
  v0.6.0
  v0.5.9
  ... more

Packages:
  github.com/google/go-cmp/cmp             Package cmp determines equality of values.
  github.com/google/go-cmp/cmp/cmpopts     Package cmpopts provides common options for the cmp package.
  ... more

The command handles pagination and formatting, allowing you to focus on the data you need for your scripts or manual investigation. To learn more, please visit pkgsite-cli’s documentation.

Stability and the future

This concludes our brief tour of the pkg.go.dev API. While we plan to expand the interface’s capabilities over time, we are committed to maintaining backward compatibility so that existing integrations continue to function seamlessly. (Note that command line interface of the pkgsite-cli reference client is not yet stable.) We welcome your feedback via our issue tracker, and we look forward to seeing the new tools and workflows the community will build.

Previous article: Type Construction and Cycle Detection
Blog Index

Read the whole story
alvinashcraft
49 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

MVP Mentoring Rings: Where Community Becomes a Catalyst

1 Share

What if mentoring did not start with matching one expert to one learner, but with bringing a small circle of community leaders together to learn out loud? That is the idea behind MVP Mentoring Rings: small, community-led groups where Microsoft Most Valuable Professionals (MVPs) share experience, ask honest questions, and help one another grow. Unlike traditional one-to-one mentoring, Mentoring Rings are built around collective learning. The result is a model that feels both practical and deeply human - especially in a global community where connection across regions, languages, and experiences matters.

Across the MVP community, Mentoring Rings have created space for something powerful: technologists showing up not just to teach, but to listen, encourage, and lead alongside one another. In a fast-moving industry, that kind of peer support can make all the difference.

More than mentoring: a circle of shared momentum

MVP Mentoring Rings were created to address a real need: even in a vibrant technical community, people can still feel isolated. The ring model offers a different path forward. Each group is intentionally small, guided by MVP Mentor Leads, and designed for recurring conversations rather than one-off advice. MVPs learn from one another through shared experiences, practical problem-solving, and accountability that grows over time.

Why did MVPs participate? For many, it was about finding community as much as guidance. Some joined to better understand how to contribute in ways that felt authentic. Others wanted a space to navigate visibility, leadership, or the challenge of translating deep technical expertise into content, talks, demos, and impact for others. MVP Mentor Leads participated for another reason too: to give back in a way that scales generosity and multiplies belonging.

When MVPs show up, others rise

The most inspiring part of Mentoring Rings is how MVPs showed up for each other. They did not arrive as polished experts with all the answers. They came ready to be open, practical, and encouraging. MVP Christine Flora, who led a Women in the MVP Program Ring, described the experience this way: “Leading a Women in the MVP Program Ring reinforced how important representation, examples of someone like yourself, and showing up as your authentic self is for confidence and connection - especially when battling imposter syndrome.”

That theme surfaced again and again: confidence grows when people feel seen. In Christine’s ring, one meaningful shift was helping participants move beyond the idea that they had to contribute exactly like someone else. As she shared, a major win was watching members realize “there are many, many ways to contribute and give to the community that fit their styles and personality types.” That is a powerful message for aspiring contributors and current MVPs alike: community leadership is not about copying a formula. It is about discovering your own voice and using it to help others.

Confidence grows in spaces built for trust

For MVP Sucheta Gawade, the value of the ring was rooted in psychological safety and clarity. She reflected that leading a ring reinforced the importance of “a psychologically safe, technical peer space” where MVPs from different domains could turn uncertainty into action. In her experience, mentoring became more than encouragement; it became a structured way to help people transform expertise into community-ready contributions such as talks, blogs, demos, and frameworks. That same sense of safety came through in MVP Agnieszka Mietz-Blijleven’s experience as a mentee. What surprised her most was how quickly trust and openness formed, even among people who had never met before. In that environment, she said, “real experience mattered more than titles” and honest reflection began to feel natural.

Sucheta also saw quiet hesitation turn into confident engagement. One of her proudest wins as a Mentoring Ring Lead was helping her group move from “I am not sure what counts as technical contributions” to a clear, trackable plan for how they could participate. That kind of progress matters because it changes how people see themselves - not just as community members, but as future speakers, writers, mentors, and leaders. Agnieszka described a similar shift from the mentee side. The ring helped her recognize that she could support others not only through empathy, but through the strength of her own experience and skills. As she put it, the experience moved her mindset from wondering whether she was doing enough to recognizing that she already brought value - and could build on it with intention.

Belonging sounds different in every language

One of the strongest lessons from Mentoring Rings is that accessibility is not only about time zones or format. It is also about language, representation, and whether people feel safe enough to participate fully. MVP Ivana Tilca, who led a New to the MVP Program ring and a Women in Tech ring in Spanish, saw how quickly those layers intersected. She shared that one of the most powerful themes in her conversations was the hesitation some women felt about asking questions or speaking up because they were often among the few women in the room - and in some cases were also navigating events and meetings in a language that was not their own. That experience, she said, changed how she thinks about community events: inclusivity cannot be an afterthought; it has to be meaningfully designed in from the start.

 

Ivana also reflected on what changed when conversations happened in Spanish. Having grown up bilingual, she said she had not always seen language as a barrier. But through the ring, she realized how much harder technical instructions, outreach, and even simple follow-up could feel for others. As she put it, “Not everyone speaks or understands English,” and for some MVPs, the language gap made “sending a simple inquiry or email feel nearly impossible” - especially when reaching out to Microsoft employees already felt intimidating. That perspective sits alongside what MVP Walter E Calcagno Lucares described in the Spanish-language ring: “Not having to translate my thoughts in real time allowed me to express myself with greater clarity and depth, which led to more strategic and meaningful conversations.” Together, their experiences make the case clearly: language-inclusive mentoring does more than remove friction. It creates trust, confidence, and a stronger sense of belonging.

From the ring to the stage: Mentoring Rings at MVP Summit

The momentum behind Mentoring Rings was also visible at MVP Summit in the session MVP Mentoring Rings: Learn, Grow, Connect. The session brought the spirit of the rings to a wider audience by centering real stories from mentors and mentees - what worked, what surprised them, and how mentoring helped both sides grow. It reinforced an important truth: mentoring in the MVP community is not one-directional. It is a shared experience that builds confidence, connection, and practical wisdom for everyone involved. Agnieszka Mietz-Blijleven captured that spirit by describing a meaningful moment from her ring: realizing how much wisdom can come from “a simple, honest conversation shared at exactly the right time.” For her, mentoring also brought perspective - showing how differently people can respond to the same situation and how often the hardest work is learning to stop criticizing yourself.

MVP Mentoring Ring Panel at MVP Summit featuring Jeremy Sinclair, Diego Domingos da Silva, Agnieszka Mietz-Blijleven, Sucheta Gawade (left to right)

Designed to leave attendees with practical tips they could use right away, the session explored how to be a thoughtful mentor, how to get more from the mentee experience, and how to build meaningful, supportive relationships in the community. MVP Diego Domingos da Silva helped bring that message to life by reframing mentoring as something far more human than a formal exchange of answers. As he shared, he joined as a mentee expecting guidance but instead found “something closer to a support group of like-minded people in the community, sharing real experiences without the pressure of a work setting.” His reflection captures what made the MVP Summit panel resonate: mentoring was not presented as hierarchy, but as honest connection.

Diego also spoke to the kind of growth that happens in these spaces. Rather than coming only from a perfectly mapped plan, he described growth as something that often takes shape through shared stories - hearing how others handled uncertainty, setbacks, and opportunity, and realizing you are not the only one figuring it out as you go. That perspective reinforced one of the panel’s strongest themes: mentoring creates momentum not because it removes uncertainty, but because it helps people move through it together.

MVP Jeremy Sinclair added another important dimension to the panel: the idea that mentoring becomes most powerful when it is reciprocal. For him, the experience was not only about guiding others, but also about paying close attention to the ways mentees were already learning, contributing, and growing in their day-to-day work. His reflection underscored one of the session’s most resonant takeaways - that the best mentoring spaces create room for everyone to teach and everyone to learn.

Agnieszka also connected mentoring to a very practical kind of growth: confidence in public speaking. She reflected that mentoring strengthened her on-stage presence by helping her stay steady in front of a live audience, navigate real-time reactions, and move through troubleshooting moments with diligence and calm. That kind of growth shows how mentoring does not stay inside the ring - it carries into talks, demos, and the visible moments where community leaders share what they know.

The invitation: learn, lead, and lift someone else up

MVP Mentoring Rings show what is possible when community leadership is shared. They help technologists grow their confidence, expand their networks, and see new possibilities for how they can contribute. They remind current MVPs that mentorship is not a side activity - it is part of how strong communities sustain themselves. As Agnieszka Mietz-Blijleven reflected, the rings create “continuity, confidence, and a culture of giving back.” And for aspiring MVPs, they offer a glimpse of what this community is really about: generosity, curiosity, and the willingness to help others thrive. 

If you are inspired by these stories, take the next step. Learn from the MVPs who are investing in others through Mentoring Rings. Look for ways to actively support and uplift people in your own tech community. Reflect on how you can be an ally - especially for those who may need representation, encouragement, or a clearer runway to be seen. And if you have been wondering whether you are ready to contribute more, start now. Share what you know, help someone take their next step, and keep building the kind of community that future MVPs will be proud to join.

Want to learn more about the MVP Program?

To find an MVP and learn more about the MVP Program visit the MVP Communities website and follow our updates on LinkedIn.

Join us for a future live session through the Microsoft Reactor where we walk through what the MVP program is about, what we look for, and how nominations work. These sessions are designed to help you connect the dots between the work you’re already doing and the impact the MVP Program recognizes - with time for questions, examples, and real conversations. 

Read the whole story
alvinashcraft
2 minutes ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories