Start Debugging

AutoMapper vs Mapperly vs hand-written mapping in 2026

Mapperly is the default for new .NET code: it matches hand-written speed, survives Native AOT, and catches unmapped members at build time. AutoMapper still wins on ProjectTo. Benchmarks and license thresholds included.

For new .NET code in 2026, use Mapperly. It generates plain C# at build time, runs within 3% of hand-written mapping, publishes clean under Native AOT, and turns a forgotten property into a compiler diagnostic instead of a silently empty string. Write mapping by hand when a project has fewer than about twenty maps or the source and destination shapes genuinely diverge. Keep AutoMapper only when ProjectTo is load-bearing across a large EF Core codebase and you qualify for the free Community tier, because above $5,000,000 in annual revenue the license turns the decision into a purchase order.

Every number below was measured on an Apple M4 (10 cores) with .NET SDK 10.0.302 targeting net10.0, using AutoMapper 16.2.0 (released 2026-07-02), Riok.Mapperly 4.3.1 (released 2025-12-22), and BenchmarkDotNet 0.15.8.

The matrix

AutoMapper 16.2.0Mapperly 4.3.1Hand-written
LicenseRPL-1.5 copyleft or paid commercialApache 2.0none
Cost above $5M revenue$799 to $6,399 per yearfreefree
How mapping is producedreflection plus compiled expression trees at first useRoslyn source generator at build timeyou
Unmapped destination membersilent, only AssertConfigurationIsValid() catches itRMG012 warning, escalatable to errorcompiler tells you nothing either
Unmapped source membernot reported at allRMG020 warningnot reported
Native AOT publishIL2104 plus IL3053, crashes at startupzero warnings, runszero warnings, runs
Cold cost of the first map~33 ms for 3 maps~1 ms0
Single object map105.79 ns60.44 ns58.48 ns
EF Core projectionProjectTo with explicit expansion, parameters, recursion depthgenerated IQueryable projection, several unsupported featureswrite the Select
Runtime Map(object, type)yesnono
Debuggable outputcompiled expression treereadable .g.cs you can step intoyour own code

The license is the axis everything else hangs off

On 2025-07-02 Jimmy Bogard moved AutoMapper and MediatR to Lucky Penny Software and relicensed both. AutoMapper 15.0.0 and later ship under a dual model: the Reciprocal Public License 1.5 for open-source use, or a paid commercial license. Version 14.x and earlier stay MIT forever.

RPL-1.5 is not MIT with extra steps. It is a strong reciprocal copyleft that reaches deployed software, not just distributed software, so closed-source commercial products cannot realistically ship on the RPL build. That leaves the commercial agreement, whose free Community tier covers organizations with less than $5,000,000 USD gross annual revenue that have also taken less than $10,000,000 USD in outside capital, and that are not government, quasi-government, or higher-education entities. Above that line the published tiers are Standard at $799 a year for 1 to 10 developers, Professional at $1,499 a year for 11 to 50, and Enterprise at $6,399 a year for unlimited developers. Only developers actively writing or maintaining code that calls the library count, which excludes QA, designers, and front-end work.

Enforcement is deliberately soft. There is no license server, no network call, and no feature lockout. A missing or expired key produces a log message and nothing else, and since 16.2.0 the key can also come from the AUTOMAPPER_LICENSE_KEY or LUCKYPENNY_LICENSE_KEY environment variables instead of cfg.LicenseKey. Soft enforcement is not the same as permission, though, and “we did not notice a warning in the logs” is not a license position anyone wants to defend in a procurement review.

This is the same fork as the mediator libraries, and the reasoning transfers directly: see MediatR vs plain service classes in 2026 for the full breakdown of the Community tier and the RPL-1.5 obligations.

When to pick Mapperly

When to pick hand-written mapping

When to keep AutoMapper

If you decide to leave, the mechanics are covered step by step in migrate from AutoMapper to source-generated mapping with Mapperly.

The benchmark

The model is an Order with five scalar members, a nested Customer, five OrderLine children, and an enum mapped to its string name. [MemoryDiagnoser], default job, AutoMapper’s expression compilation warmed in [GlobalSetup] so the measurement is steady-state throughput rather than first-call cost.

// .NET SDK 10.0.302, net10.0, C# 14
// AutoMapper 16.2.0, Riok.Mapperly 4.3.1, BenchmarkDotNet 0.15.8
[MemoryDiagnoser]
public class MappingBenchmarks
{
    private Order _order = null!;
    private List<Order> _orders = null!;
    private IMapper _autoMapper = null!;
    private OrderMapper _mapperly = null!;

    [GlobalSetup]
    public void Setup()
    {
        _order = MakeOrder(1);
        _orders = Enumerable.Range(1, 1000).Select(MakeOrder).ToList();

        var config = new MapperConfiguration(
            cfg => cfg.AddProfile<OrderProfile>(),
            NullLoggerFactory.Instance);
        _autoMapper = config.CreateMapper();
        _mapperly = new OrderMapper();

        _autoMapper.Map<OrderDto>(_order); // warm the expression compilation
    }

    [Benchmark(Baseline = true)]
    public OrderDto HandWritten_Single() => HandMapper.ToDto(_order);

    [Benchmark]
    public OrderDto Mapperly_Single() => _mapperly.ToDto(_order);

    [Benchmark]
    public OrderDto AutoMapper_Single() => _autoMapper.Map<OrderDto>(_order);
}

Results on an Apple M4, 10 physical cores, .NET 10.0.10 Arm64 RyuJIT:

MethodMeanRatioAllocatedAlloc Ratio
HandWritten_Single58.48 ns1.00624 B1.00
Mapperly_Single60.44 ns1.03624 B1.00
AutoMapper_Single105.79 ns1.81704 B1.13
HandWritten_100072,696 ns1.00632,091 B1.00
Mapperly_100077,334 ns1.06672,093 B1.06
AutoMapper_1000103,376 ns1.42720,640 B1.14

Read this honestly: 45 nanoseconds per object is not why you should switch. On a request that maps 1,000 orders the whole difference is 31 microseconds, which will not show up next to a single database round trip. The performance argument only becomes load-bearing at very high object counts, and it is the weakest of the three reasons to prefer Mapperly.

The 40,000-byte gap between Mapperly and hand-written in the 1,000-object case is a real artifact worth understanding. Mapperly widens the parameter of a generated nested-collection mapper to IReadOnlyCollection<T>:

// Riok.Mapperly 4.3.1 generated output, trimmed
private List<OrderLineDto> MapToListOfOrderLineDto(IReadOnlyCollection<OrderLine> source)
{
    var target = new List<OrderLineDto>(source.Count);
    foreach (var item in source)
        target.Add(MapToOrderLineDto(item));
    return target;
}

Enumerating a List<T> through an interface boxes its struct enumerator: 40 bytes per order, 40,000 bytes across the batch. Declaring the nested collection mapper yourself with a concrete List<OrderLine> parameter removes it. This is exactly the kind of thing you can find and fix because the generated code is on disk, which is the practical difference between a source generator and a compiled expression tree.

The gotcha that picks for you: Native AOT

Publish a console app that calls AutoMapper 16.2.0 with <PublishAot>true</PublishAot> on net10.0 and the build warns:

AutoMapper.dll : warning IL2104: Assembly 'AutoMapper' produced trim warnings.
AutoMapper.dll : warning IL3053: Assembly 'AutoMapper' produced AOT analysis warnings.

Warnings are easy to ignore. The resulting binary is not:

Unhandled exception. System.TypeInitializationException: A type initializer threw an exception.
 ---> System.ArgumentNullException: Value cannot be null. (Parameter 'method')
   at System.Linq.Expressions.Expression.Call(MethodInfo, Expression)
   at AutoMapper.Execution.ExpressionBuilder..cctor()
   at AutoMapper.MapperConfiguration..ctor(MapperConfigurationExpression, ILoggerFactory)

The trimmer removed a method that ExpressionBuilder looks up reflectively, so the static constructor dies before your first map. The equivalent Mapperly app published with the same settings emits zero IL warnings, produces a 1.1 MB native binary, and runs. That is not a tuning problem you can solve with DynamicDependency attributes at the call site; it is a property of building maps from expression trees at runtime, which is the same trap described in what is trim-safe code and how do I write it. If Native AOT is on your roadmap, the decision is already made.

The softer version of the same effect is cold start. Building the configuration and running the first map for three types took 33 milliseconds on this machine, versus 1 millisecond for new OrderMapper() plus its first call. In a long-lived web app that is invisible. In a Lambda it is a measurable slice of a cold invocation, which is why it shows up in reducing cold-start time for a .NET AWS Lambda.

Where the safety difference actually shows up

Add a Slug property to a DTO and forget to map it. AutoMapper 16.2.0 maps the object anyway:

map ok: Id=1 Name=n Slug=''

AssertConfigurationIsValid() does catch it, throwing AutoMapperConfigurationException with “Unmapped members were found” - but only if you remembered to call it, and only for unmapped destination members. A source property that no longer reaches any DTO is not reported at all.

Mapperly reports both directions at build time, with the real message text:

warning RMG020: The member InternalNote on the mapping source type Diag.Source
                is not mapped to any member on the mapping target type Diag.Target
warning RMG012: The member Slug on the mapping target type Diag.Target
                was not found on the mapping source type Diag.Source

They are warnings by default, which means they will drown in a noisy build. Escalate them in .editorconfig and the build fails outright:

[*.cs]
dotnet_diagnostic.RMG012.severity = error
dotnet_diagnostic.RMG020.severity = error

That is the setting that turns Mapperly from “a faster AutoMapper” into a different category of tool: mapping bugs stop being production incidents and become build failures. It is also the clearest illustration of why source generators are worth the build-time dependency.

Hand-written mapping, for the record, offers no such check. A forgotten assignment in a ToDto method is exactly as silent as AutoMapper. Its safety comes from being visible in code review, not from tooling.

The call

Default to Mapperly for new code, and escalate RMG012 and RMG020 to errors on day one so you actually get the benefit. Write mapping by hand when the project is small or the shapes are irregular, and accept that you are trading tooling checks for reviewability. Keep AutoMapper when a mature ProjectTo-heavy codebase already works, you are under the Community threshold, and Native AOT is not on the roadmap - and if any one of those three stops being true, start the migration rather than budgeting for the license. The performance table is the least interesting part of this comparison. Trim safety and build-time diagnostics are what actually change how a codebase behaves.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back