The compile-time object mapper. Declare the mapping; the compiler writes it, checks it, and shows you the code.
Migrating off AutoMapper? Mapwright is MIT-licensed and free for commercial use, with no runtime component to license, register, or warm up. Every piece of a
Profilehas a direct replacement, and the runtime check you had to remember to write becomes the build:
AutoMapper Mapwright Profile+CreateMap<TSource, TDest>()a [Mapper]class with onestatic partialmethod per mappingIMapperinjected everywherecall the generated method directly — or an instance mapper behind an interface when you want DI ProjectTo<TDest>()a Expression<Func<TSource, TDest>>orIQueryable<TDest>partial — EF translates it because it is a hand-written projectionIncludeAllDerived()[MapDerivedType(typeof(CarEntity), typeof(CarDto))]BeforeMap/AfterMap[BeforeMap]/[AfterMap]custom value resolvers needing services a hand-written method on the mapper; on an instance mapper it can use constructor-injected dependencies AssertConfigurationIsValid()in a testthe compiler, on every keystroke — and a rename that orphans an ignore is a build error Start with the migration walkthrough, or the side-by-side reference whose sample output is printed from a real runnable app. Weighing the alternatives? Mapwright vs Mapperly vs AutoMapper is honest about where each one wins — including when to stay where you are.
Don't rewrite the Profiles by hand.
dotnet add package Mapwright.Migrationadds an analyzer that offers "Convert to a Mapwright mapper" on everyAutoMapper.Profile, writing the equivalent[Mapper]class beside it and flagging — never dropping — anything it cannot translate. It is a build-time-only package you remove when the migration is done. See migrating with the analyzer.
AutoMapper resolves mappings at runtime, by reflection and convention: a renamed property
maps to nothing, silently, until AssertConfigurationIsValid() fails in a test — or
nobody notices at all. Mapwright inverts the model. You declare each mapping as a
static partial method; a Roslyn incremental source generator writes the implementation
as plain C# you can read, diff, and step through; and every destination property that
nothing maps is a compiler diagnostic, not a runtime surprise.
[Mapper]
public static partial class CatalogMapper
{
// Domain record -> EF entity. Audit fields are the database's business.
[MapIgnore(nameof(CodeEntity.User), nameof(CodeEntity.Created), nameof(CodeEntity.Modified))]
public static partial CodeEntity ToEntity(Code source);
// EF entity -> domain record. bool? columns collapse to non-nullable domain booleans.
public static partial Code ToDomain(CodeEntity source);
// In-place scalar copy onto the EF-tracked instance — the Repository.Update pattern.
[MapIgnore(nameof(CodeEntity.User), nameof(CodeEntity.Created), nameof(CodeEntity.Modified))]
public static partial void CopyScalars(CodeEntity source, CodeEntity target);
// The ProjectTo replacement: an expression EF Core translates to SQL.
public static partial Expression<Func<CodeEntity, CodeSummary>> SummaryProjection();
// Collection overload, wired to the ToEntity map above.
public static partial List<CodeEntity> ToEntities(IEnumerable<Code> source);
}That is the entire mapping layer. No profiles, no MapperConfiguration, no IMapper to
inject, nothing to register in DI. Call CatalogMapper.ToEntity(code) like the ordinary
method it is.
- Verification is the build. AutoMapper's
AssertConfigurationIsValid()runs when a test remembers to call it. Mapwright's equivalent (MW0001) runs on every keystroke, in the IDE, before the code ever executes — and stale configuration is an error:[MapIgnore("RemovedLastSprint")]referring to a property that no longer exists fails the build (MW0003), so ignore lists cannot rot. - The mapping is code you own. Generated implementations land in ordinary
.g.csfiles: object initializers,GetValueOrDefault()where a nullable column collapses, a null-guard before a nested map. Breakpoints work.git diffof behavior is possible. There is no runtime black box to reverse-engineer at 2 a.m. - Nothing happens at runtime. The
Mapwrightpackage is attributes only. No reflection, no expression compilation, no cache warm-up — which also means Native AOT and trimming just work, and the only allocation is the destination object itself. - EF Core projections without the magic. A
Expression<Func<TSource, TDest>>partial method generates the whole projection shape — nested maps inlined as initializers — soqueryable.Select(CatalogMapper.SummaryProjection())translates to SQL exactly like a hand-written projection, because it is one.
dotnet add package Mapwright
That is the whole install. Mapwright holds the attributes; it depends on
Mapwright.Generator, the build-time analyzer that writes the code, so one reference brings
both. (Referencing Mapwright.Generator explicitly, as versions before 1.2.0 required, still
works — NuGet resolves the package once either way.)
Supported frameworks: the attributes package targets .NET Standard 2.0, .NET 8, and
.NET 10, so it references cleanly from .NET Framework 4.6.1+, .NET Core, and modern .NET
alike. The generator is a build-time analyzer — it ships no lib/, adds no runtime
dependency, and adapts the code it writes to whatever your project targets.
Two requirements on older targets, both one-time: set <LangVersion>10.0</LangVersion>
(or latest), because the generated file uses file-scoped namespaces and nullable
annotations that the C# 7.3 default predates; and use an SDK-style project, because
Roslyn does not run source generators in legacy non-SDK .csproj files. Verified end to
end on .NET Framework 4.8 with the .NET 9 SDK: it builds, runs, and the generator falls
back to classic null guards and Add loops where the modern APIs are absent.
Building from source instead: reference
src/Mapwright/Mapwright.csprojnormally andsrc/Mapwright.Generator/Mapwright.Generator.csprojwithOutputItemType="Analyzer" ReferenceOutputAssembly="false".
| Declare | Get |
|---|---|
static partial TDest Name(TSource s) |
Object map: initializer, null-guard, ArgumentNullException on null source. |
static partial void Name(TSource s, TDest t) |
In-place copy for EF-tracked instances; init-only members are flagged (MW0006), never half-copied. With ExistingTargets = Merge, nested objects and keyed collections are updated in place. |
static partial Expression<Func<TSource, TDest>> Name() |
EF-translatable projection; nested maps inlined, cycles rejected (MW0007). |
static partial List<TDest> Name(IEnumerable<TSource> s) |
Collection map delegating to the sibling (or synthesized) object map. Arrays and sets too. |
static partial IQueryable<TDest> Name(IQueryable<TSource> s) |
The ProjectTo replacement: source.Select(...) with the whole shape inlined, EF-translatable. |
Matching: case-insensitive by name (CodeID → CodeId needs nothing), [MapProperty]
for real renames, inherited members included, T? → T collapses via visible
GetValueOrDefault(), nested objects and collections route through sibling maps.
Construction: the generator picks the public constructor with the most
parameters whose every parameter matches a source member — so positional records map
through their primary constructor, remaining members flow through the object
initializer, and classes keep their parameterless behavior. required members must be
mapped or the build fails (MW0012); a destination nothing can construct is MW0010.
Enums: enum-to-enum members map by numeric cast by default, or by name
with [Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] — a generated,
readable switch; source members with no same-named destination member are flagged
(MW0011). Projections always cast (expression trees cannot contain switches).
Dictionaries & sets: Dictionary<K,V>/IDictionary/IReadOnlyDictionary
members map when keys agree and values are identical or element-mappable;
HashSet<T>/ISet<T>/IReadOnlySet<T> join the supported collection shapes.
Auto-synthesized nested maps: a nested object or element pair with no declared
sibling map gets a private helper map synthesized in the generated file — readable,
steppable, and reported on (MW0001 still fires inside it). Declared maps always win;
cyclic object graphs resolve to recursive calls. Opt out per mapper with
[Mapper(AutoNestedMaps = false)] to restore the strict declare-every-pair contract.
Instance & interface mappers: [Mapper] public partial class M : IM with
instance partial methods works — register the class in DI and inject the interface.
Flattening: CustomerName resolves to Customer.Name by convention when no
direct member matches (up to three hops, null-guarded), and [MapProperty] accepts
dotted source paths ([MapProperty("Customer.Id", "Buyer")]).
Unflattening: [MapProperty] also accepts dotted target paths —
[MapProperty("CustomerName", "Customer.Name")] builds the nested destination object,
grouping every rename that shares a head and recursing for deeper paths. Works in
projections. And rename targets are now rot-checked like everything else: a [MapProperty]
pointing at a member that no longer exists fails the build (MW0003).
Scalar conversions: numbers, dates, Guid, bool and enums convert to
string; string parses to all of them; DateTime ↔ DateOnly/TimeOnly bridge — all
culture-invariant, dates via round-trip "o". A hand-written method for the pair always
wins; projections stay conversion-free so the tree remains translatable.
Derived types: [MapDerivedType(typeof(CarEntity), typeof(CarDto))] turns an
object map into a runtime-type switch; unmatched types map as the base pair when
constructible and throw otherwise (invalid pairs are MW0013 build errors).
Custom methods: any hand-written static method on the mapper with shape
TDest Name(TSource) is picked up as the map for that pair — nested resolution and
scalar overrides use it automatically. On an instance mapper the method may be an
instance method, so a constructor-injected service can act as a value converter;
static maps and projections still only ever bind static custom methods, because neither
can call one.
Generic mappers: declare TTarget Map<TSource, TTarget>(TSource source) (or
void Update<TSource, TTarget>(TSource source, TTarget target)) alongside the concrete
maps and the generator emits a typeof dispatch chain over every map declared on the
class. Each closed instantiation folds to a single direct call; a pair with no declared
map throws an ArgumentException naming both types. Type-parameter constraints are
honored — a pair that cannot satisfy them is never an arm.
Deep cloning: [Mapper(DeepClone = true)] makes same-type reference members
(and their collections/dictionary values) clone through synthesized maps instead of
copying references — T Clone(T source) duplicates the whole graph.
Graph merge (1.3): an in-place copy normally replaces the nested objects and
collections the target already holds — which, on an EF Core tracked aggregate, turns an
update into a delete-and-insert of every child row. [Mapper(ExistingTargets = ExistingTargetStrategy.Merge)] updates the existing graph instead: a nested object the
target already has is copied into, not replaced, and a collection is merged by key —
matching elements updated in place, new ones added, elements the source no longer contains
removed. The key is Id, Key, or <TypeName>Id by convention (LineId on LineEntity);
a collection with no recognizable key is a build error (MW0016), never a silent replace.
One collection can opt in on its own, with its own key and without removals:
[MergeCollection(nameof(Order.Lines), Key = nameof(OrderLine.Sku), RemoveMissing = false)]
public static partial void Patch(OrderDto source, Order target);The full story, with the delete-and-insert bug it fixes, is in Updating an EF Core aggregate from a DTO without deleting every child row.
Get-only collections (public ICollection<Line> Lines { get; } = new List<Line>();, the EF
idiom) are merged into as well, since a merge never assigns the member. List<T>,
HashSet<T>, IList<T>, ICollection<T> and ISet<T> targets merge; arrays and read-only
interfaces are replaced as before. The generated merge is a dictionary of existing elements
by key, one loop, and a RemoveAll — code you can read and step through, like everything
else. Cyclic instance graphs are safe: EF back-references (Line.Order) are the norm on a
tracked aggregate, so a merge tracks the target instances it has already updated and merges
each one once per call. Neither Mapperly nor AutoMapper without its Collection extension does
this.
Escape hatches: [BeforeMap] runs before, [AfterMap] after the generated
assignments — for computed values and everything a generator shouldn't guess.
| Id | Severity | Meaning |
|---|---|---|
MW0001 |
Warning | Destination property is not mapped — map it, [MapIgnore] it, or set it in AfterMap. |
MW0002 |
Info | Source property is never read — [MapIgnoreSource] documents one-way fields. |
MW0003 |
Error | Ignore/rename names a property that does not exist (configuration rot). |
MW0004 |
Error | No conversion between the matched property types. |
MW0005 |
Error | Method signature is not a recognized mapping shape. |
MW0006 |
Warning | Init-only property cannot be set by an in-place copy. |
MW0007 |
Error | Projection would recurse forever. |
MW0008 |
Error | Collection map lacks its element map. |
MW0009 |
Error | AfterMap target is missing or has the wrong signature. |
MW0010 |
Error | Destination has no public constructor whose parameters all match source members. |
MW0011 |
Warning | By-name enum map: a source member has no same-named destination member. |
MW0012 |
Error | A required member is neither mapped nor passed through a constructor. |
MW0013 |
Error | A [MapDerivedType] pair does not derive from the base pair, or has no map. |
MW0014 |
Error | BeforeMap target is missing, or used on a projection. |
MW0015 |
Error | A generic mapping method is not a recognized dispatch shape. |
MW0016 |
Error | A merged collection has no recognizable key — name one with [MergeCollection(..., Key = ...)]. |
MW0017 |
Error | The merge key is missing on one element type, or its types differ. |
MW0018 |
Error | [MergeCollection] names something that cannot be merged in place (array, read-only interface, non-collection, or a method that is not an in-place copy). |
Promote MW0001 to an error in .editorconfig when you want AutoMapper-strictness:
dotnet_diagnostic.MW0001.severity = errorGenerated code runs at hand-written speed, because it is hand-written-style code.
benchmarks/Mapwright.Benchmarks maps an order with a nested customer and ten lines,
four ways (BenchmarkDotNet, .NET 10, Intel i5-1035G1; read the ratios and allocations —
absolute nanoseconds are machine-specific):
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| Hand-written, single order | 117.6 ns | 1.00 | 768 B |
| Mapwright, single order | 105.3 ns | 0.90 | 696 B |
| Mapperly, single order | 96.6 ns | 0.82 | 696 B |
| AutoMapper 13, single order | 242.8 ns | 2.07 | 888 B |
| Hand-written, 100 orders | 13.2 µs | 1.00 | 77,728 B |
| Mapwright, 100 orders | 10.8 µs | 0.92 | 70,456 B |
| Mapperly, 100 orders | 12.0 µs | 1.02 | 70,456 B |
| AutoMapper 13, 100 orders | 20.6 µs | 1.75 | 90,992 B |
On a collection — the case that dominates real workloads — Mapwright is the fastest
of the four: ~10% ahead of Mapperly and ahead of hand-written LINQ itself, because the
element loop writes straight into a pre-sized List backing span
(CollectionsMarshal.SetCount + AsSpan) — no enumerator, no per-element delegate, no
Add capacity checks. Allocations tie Mapperly exactly. On a single object Mapperly keeps
a ~8% edge (less call indirection in a tiny domain), and AutoMapper pays 1.75–2.07× for
runtime resolution across the board. dotnet run -c Release in the benchmarks folder
reproduces the table.
The span path needs CollectionsMarshal.SetCount, so it is what .NET 8 and later get;
targets without it get the same loop pre-sized with Add instead — same result, same
allocations, one extra bounds check per element.
docs/automapper-vs-mapwright.html — the side-by-side
migration reference: why AutoMapper exists, the issues it leaves open, how Mapwright closes each
with modern C#, a statement-by-statement replacement guide, and a runnable sample app whose real
output is printed in the doc (samples/CatalogApi.Sample — dotnet run --project samples/CatalogApi.Sample).
docs/replacing-automapper.html is the full
walkthrough, built on a real production case study: a catalog API whose AutoMapper
MapperFactory (entity↔domain maps, self-maps for tracked updates, ForMember(...Ignore())
lists) became three Mapwright declarations per entity — with the runtime
AssertConfigurationIsValid() test replaced by the compiler.
docs/automapper-and-mapwright-explained.html — a from-zero explanation of what AutoMapper is and how source-generated mapping replaces it.
Every Mapperly-parity milestone is done; from here the roadmap is Mapwright's own. 1.0 means the attribute surface and generated-code contract are stable: additions are welcome, but a mapping that compiles today will keep compiling and keep meaning the same thing. Breaking changes wait for 2.0.
- Constructor & positional-record mapping;
required-member enforcement (0.2) - Enum maps, by value and by name (0.2)
- Dictionaries and sets (0.2)
- Auto-synthesized nested maps — undeclared pairs get private helper maps (0.3)
- Instance & interface mappers for DI (0.3)
-
IQueryable<TDest> Name(IQueryable<TSource>)— theProjectToshape (0.3) - Flattening + dotted
[MapProperty]paths (0.4) - Derived-type / polymorphic mapping (0.4)
- Built-in scalar conversions, culture-invariant (0.4)
- Hand-written method resolution (0.4)
- Deep cloning (
DeepClone = true) and[BeforeMap](0.4) - Unflattening — dotted
[MapProperty]target paths; rename-target rot detection (0.5) - Benchmarks vs AutoMapper and Mapperly; incremental-caching regression test (0.5)
- NuGet packaging & keyless release automation — live on NuGet.org (0.5)
- Generic mappers —
TTarget Map<TSource, TTarget>(TSource)dispatching to the declared maps, plus the existing-targetvoid Update<TSource, TTarget>(TSource, TTarget)(0.6) - Hand-written instance methods as custom maps — DI-resolved value converters on instance mappers (0.6)
- Multi-targeting — netstandard2.0/net8.0/net10.0; the generator adapts the code it writes to whatever the consuming project targets (1.0)
- Graph merge —
ExistingTargets = Mergeand[MergeCollection]: in-place copies update nested instances and merge collections by key instead of replacing them (1.3)
| Project | What it is |
|---|---|
src/Mapwright |
The attributes. The entire runtime surface — nothing executes. |
src/Mapwright.Generator |
The Roslyn incremental generator and its diagnostics. |
tests/Mapwright.Tests |
Behavior tests over real generated mappers + diagnostic tests driving the generator in-memory. |
MIT — see LICENSE.