Start Debugging

How to add custom table-name pluralization to dotnet ef dbcontext scaffold with IPluralizer

A table named Gas scaffolds to an entity called Ga. Replace the built-in Humanizer pluralizer with your own IPluralizer, register it through IDesignTimeServices in the startup project, and learn why TryAddSingleton silently does nothing there.

Short answer: write a class that implements Microsoft.EntityFrameworkCore.Design.IPluralizer (two methods, Pluralize and Singularize), then register it from a class that implements IDesignTimeServices in your startup project with services.AddSingleton<IPluralizer, MyPluralizer>(). Use AddSingleton, never TryAddSingleton: EF Core registers HumanizerPluralizer before your code runs, so a TryAdd is a silent no-op and you will spend an hour wondering why nothing changed. dotnet ef dbcontext scaffold then calls your Singularize for entity type names and your Pluralize for DbSet names and collection navigations. If you also need to strip a tbl_ prefix, that is a different service, ICandidateNamingService, and it runs before the pluralizer.

Everything below was run on the .NET 11 RC 1 SDK (11.0.100-rc.1.26425.128) with dotnet-ef 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite and Microsoft.EntityFrameworkCore.Design 11.0.0-rc.1.26425.128, and Humanizer.Core 3.0.10. The database is a local SQLite file, because nothing in this post depends on the provider: pluralization happens in Microsoft.EntityFrameworkCore.Design, above the provider layer. Every generated name quoted here is real dotnet ef output, not a reconstruction.

A table named Gas scaffolds to an entity named Ga

Here is a six-table schema with the kinds of names that real databases actually contain: two German words, two English words that end in s while being singular, one Latin plural, and one legacy tbl_ prefix.

-- SQLite, shop.db
CREATE TABLE Kunde (KundeId INTEGER PRIMARY KEY, Name TEXT NOT NULL);
CREATE TABLE Bestellung (BestellungId INTEGER PRIMARY KEY, KundeId INTEGER NOT NULL REFERENCES Kunde(KundeId), Summe REAL NOT NULL);
CREATE TABLE Gas (GasId INTEGER PRIMARY KEY, Formula TEXT NOT NULL);
CREATE TABLE Canvas (CanvasId INTEGER PRIMARY KEY, Width INTEGER NOT NULL);
CREATE TABLE Media (MediaId INTEGER PRIMARY KEY, Url TEXT NOT NULL);
CREATE TABLE tbl_person (person_id INTEGER PRIMARY KEY, full_name TEXT NOT NULL);

Scaffold it with no customization at all:

dotnet ef dbcontext scaffold "Data Source=shop.db" Microsoft.EntityFrameworkCore.Sqlite -o Gen --context ShopContext -f

The generated files are Bestellung.cs, Canva.cs, Ga.cs, Kunde.cs, Medium.cs, TblPerson.cs, and the context looks like this:

// Generated by dotnet ef 11.0.0-rc.1.26425.128, default services
public virtual DbSet<Bestellung> Bestellungs { get; set; }
public virtual DbSet<Canva> Canvas { get; set; }     // entity type is Canva
public virtual DbSet<Ga> Gas { get; set; }           // entity type is Ga
public virtual DbSet<Kunde> Kundes { get; set; }
public virtual DbSet<Medium> Media { get; set; }
public virtual DbSet<TblPerson> TblPeople { get; set; }

Four separate problems in six tables. Gas and Canvas are singular nouns that end in s, and the singularizer chops the s off anyway. Kunde and Bestellung get English plural rules applied to German words. Media becomes Medium, which is correct Latin and almost never what a Media table means. And tbl_person keeps its prefix, then gets pluralized into the genuinely surprising TblPeople.

This is not a bug so much as the inevitable result of applying one language’s morphology to arbitrary identifiers. It is worth seeing how wide the blast radius is. Calling HumanizerPluralizer directly on a word list gives:

input           Singularize     Pluralize
Gas             Ga              Gas
Canvas          Canva           Canvas
Atlas           Atla            Atlas
Census          Censu           Census
Corpus          Corpu           Corpus
Bonus           Bonu            Bonus
Nexus           Nexu            Nexus
GPS             GP              GPs
Media           Medium          Media
Data            Datum           Data
Criteria        Criterion       Criteria
Index           Index           Indices
Focus           Focus           Foci
Radius          Radius          Radii
Status          Status          Statuses
Bestellung      Bestellung      Bestellungs
Usuario         Usuario         Usuarios

Two things are worth pulling out of that table. First, the -us and -as family is consistently mangled in the singular direction: any table whose name ends in a non-plural s loses a character. Second, the Latin plurals (Indices, Foci, Radii) are technically defensible and still wrong for most codebases, which expect Indexes. Status used to be in the broken group and is handled correctly now.

Version note: these results are identical on Humanizer.Core 2.14.1, which is what Microsoft.EntityFrameworkCore.Design 8.0.x through 10.0.x depend on, and on 3.0.10, which EF Core 11 RC 1 moved to. Upgrading EF Core will not fix any of this for you.

Where the pluralizer sits in the naming pipeline

Before writing a replacement it helps to know exactly what string your methods receive, because that is not obvious from the interface.

RelationalScaffoldingModelFactory builds two namers per scaffold run:

// efcore, src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs, v11.0.0-rc.1.26425.128
_tableNamer = new CSharpUniqueNamer<DatabaseTable>(
    options.UseDatabaseNames ? (t => t.Name) : _candidateNamingService.GenerateCandidateIdentifier,
    _cSharpUtilities,
    options.NoPluralize ? null : _pluralizer.Singularize,
    caseSensitive: false);
_dbSetNamer = new CSharpUniqueNamer<DatabaseTable>(
    options.UseDatabaseNames ? (t => t.Name) : _candidateNamingService.GenerateCandidateIdentifier,
    _cSharpUtilities,
    options.NoPluralize ? null : _pluralizer.Pluralize,
    caseSensitive: true);

So the entity type name is Singularize(candidateIdentifier) and the DbSet property name is Pluralize(candidateIdentifier), from the same input. Both funnel through CSharpUtilities.GenerateCSharpIdentifier, which fixes the order of operations:

  1. ICandidateNamingService.GenerateCandidateIdentifier turns the raw table name into a Pascal-cased candidate (tbl_person becomes TblPerson), unless --use-database-names is set.
  2. Characters that are not valid in a C# identifier are replaced with _.
  3. Your pluralizer runs, on the result of steps 1 and 2.
  4. A leading _ is prepended if the result starts with a digit or is a C# keyword.
  5. A uniquifier appends 1, 2, and so on if the name is already taken.

Step 3 is the important one: you receive a whole Pascal-cased identifier such as CustomerAddress or TblPerson, not a single word. A dictionary keyed on bare nouns will miss almost everything unless you split the identifier first.

The entity namer is case-insensitive for uniqueness and the DbSet namer is case-sensitive, which is why Media can be both the entity type and the DbSet name without a collision.

Collection navigations are pluralized separately, by an explicit _pluralizer.Pluralize call on the candidate navigation name, and reference navigations are not singularized at all. All four call sites are guarded by if (!_options.NoPluralize).

An IPluralizer that handles whole identifiers

The implementation below keeps Humanizer as the fallback (it is right far more often than it is wrong) and overrides only the words your schema actually contains. One table of pairs drives both directions, which matters more than it sounds like: the first version I wrote only had a singular-to-plural map, fixed every DbSet name, and left the entity types called Ga and Canva, because Singularize("Gas") never found a match.

// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128, Humanizer.Core 3.0.10.
using Humanizer;
using Microsoft.EntityFrameworkCore.Design;

namespace Shared;

public sealed class DomainPluralizer : IPluralizer
{
    // One row per word the English rules get wrong. Both directions come from this list.
    private static readonly (string Singular, string Plural)[] Words =
    [
        ("Kunde", "Kunden"),
        ("Bestellung", "Bestellungen"),
        ("Gas", "Gases"),
        ("Canvas", "Canvases"),
        ("Bonus", "Bonuses"),
        ("Status", "Statuses"),
        ("Media", "Media"),
        ("Aircraft", "Aircraft"),
        ("Person", "Persons")
    ];

    private static readonly Dictionary<string, string> ToPlural = BuildToPlural();
    private static readonly Dictionary<string, string> ToSingular = BuildToSingular();

    private static Dictionary<string, string> BuildToPlural()
    {
        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var (singular, plural) in Words)
        {
            map[singular] = plural;
            map[plural] = plural;
        }

        return map;
    }

    private static Dictionary<string, string> BuildToSingular()
    {
        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var (singular, plural) in Words)
        {
            map[plural] = singular;
            map[singular] = singular;
        }

        return map;
    }

    public string Pluralize(string identifier)
        => Transform(identifier, ToPlural, word => word.Pluralize(inputIsKnownToBeSingular: false));

    public string Singularize(string identifier)
        => Transform(identifier, ToSingular, word => word.Singularize(inputIsKnownToBePlural: false));

    private static string Transform(
        string identifier,
        IReadOnlyDictionary<string, string> overrides,
        Func<string, string> fallback)
    {
        if (string.IsNullOrEmpty(identifier))
        {
            return identifier;
        }

        if (overrides.TryGetValue(identifier, out var whole))
        {
            return whole;
        }

        var (prefix, trailing) = SplitTrailingWord(identifier);
        return overrides.TryGetValue(trailing, out var mapped)
            ? prefix + mapped
            : prefix + fallback(trailing);
    }

    private static (string Prefix, string Trailing) SplitTrailingWord(string identifier)
    {
        for (var i = identifier.Length - 1; i > 0; i--)
        {
            if (identifier[i] == '_')
            {
                return (identifier[..(i + 1)], identifier[(i + 1)..]);
            }

            if (char.IsUpper(identifier[i]) && !char.IsUpper(identifier[i - 1]))
            {
                return (identifier[..i], identifier[i..]);
            }
        }

        return ("", identifier);
    }
}

Three design decisions worth calling out. Mapping each singular to itself in ToSingular (and each plural to itself in ToPlural) is what makes the methods idempotent, which the interface documentation asks for: “Returns the same identifier if it is already pluralized.” Splitting on the last Pascal-case boundary means InvoiceStatus and ShippingStatus both pick up the Status rule without separate entries. And SplitTrailingWord also breaks on _, which only matters under --use-database-names, where the raw tbl_person reaches your code unchanged.

Registering it so dotnet ef actually uses it

Microsoft.EntityFrameworkCore.Design is a DevelopmentDependency package, so a default dotnet add package gives you a reference you cannot compile against. Remove the IncludeAssets metadata:

<!-- Shared.csproj, .NET 11 -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128">
  <PrivateAssets>none</PrivateAssets>
</PackageReference>
<PackageReference Include="Humanizer.Core" Version="3.0.10" />

Reference Humanizer.Core explicitly if you use it as your fallback. It arrives transitively today, but relying on the version EF Core happens to pin is how you get a surprise when you upgrade.

Then the registration itself:

// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128.
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;

namespace Shared;

public sealed class SharedDesignTimeServices : IDesignTimeServices
{
    public void ConfigureDesignTimeServices(IServiceCollection services)
        => services.AddSingleton<IPluralizer, DomainPluralizer>();
}

Use AddSingleton, not TryAddSingleton. DesignTimeServicesBuilder.CreateServiceCollection calls services.AddEntityFrameworkDesignTimeServices(...) and only then ConfigureUserServices(services). EF’s own registration is TryAddSingleton<IPluralizer, HumanizerPluralizer>(), so by the time your method runs, IPluralizer is already in the collection. AddSingleton appends, and Microsoft.Extensions.DependencyInjection resolves the last registration, so yours wins. TryAddSingleton sees the existing entry and does nothing. I ran the scaffold both ways against the same schema: with TryAddSingleton the output was byte-identical to the default run, entity types Ga and Canva included, with no warning of any kind.

Two more discovery rules, both straight out of DesignTimeServicesBuilder:

To share the pluralizer across several projects, put it in its own assembly and point at it with an assembly-level attribute. That path is handled by ConfigureReferencedServices, which scans both the startup assembly and the target assembly for DesignTimeServicesReferenceAttribute:

// AssemblyInfo.cs in the project you scaffold into
using Microsoft.EntityFrameworkCore.Design;

[assembly: DesignTimeServicesReference("Shared.SharedDesignTimeServices, Shared")]

That runs before AddEntityFrameworkDesignTimeServices, so on this path a TryAddSingleton would also work, because EF’s own TryAdd becomes the no-op instead. Using AddSingleton everywhere means you do not have to keep that distinction in your head.

The result

Same schema, same command, with DomainPluralizer registered:

TableDefault entityDefault DbSetCustom entityCustom DbSet
KundeKundeKundesKundeKunden
BestellungBestellungBestellungsBestellungBestellungen
GasGaGasGasGases
CanvasCanvaCanvasCanvasCanvases
MediaMediumMediaMediaMedia
tbl_personTblPersonTblPeopleTblPersonTblPersons

The collection navigation on Kunde follows the DbSet name, because it goes through the same Pluralize call:

public virtual ICollection<Bestellung> Bestellungen { get; set; } = new List<Bestellung>();

Stripping a tbl_ prefix is a different service

TblPerson is still there, and no pluralizer can fix it, because by the time your code is called the prefix is already baked into the candidate identifier. Prefix stripping belongs to ICandidateNamingService, which runs at step 1. Derive from the built-in implementation and override one method:

// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128.
#pragma warning disable EF1001
using Microsoft.EntityFrameworkCore.Scaffolding.Internal;
using Microsoft.EntityFrameworkCore.Scaffolding.Metadata;

namespace Shared;

public sealed class TablePrefixNamingService : CandidateNamingService
{
    private static readonly string[] Prefixes = ["tbl_", "tbl"];

    public override string GenerateCandidateIdentifier(DatabaseTable table)
    {
        var name = table.Name;
        foreach (var prefix in Prefixes)
        {
            if (name.Length > prefix.Length
                && name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            {
                name = name[prefix.Length..];
                break;
            }
        }

        return GenerateCandidateIdentifier(name);
    }
}

CandidateNamingService lives in a namespace ending in .Internal and is decorated accordingly, so you need #pragma warning disable EF1001 or <NoWarn>$(NoWarn);EF1001</NoWarn>. That is the price of the extension point, and the same warning shows up in every serious scaffolding customization, including EF Core Power Tools, which drives the same services from a UI.

Register it alongside the pluralizer:

public void ConfigureDesignTimeServices(IServiceCollection services)
{
    services.AddSingleton<IPluralizer, DomainPluralizer>();
    services.AddSingleton<ICandidateNamingService, TablePrefixNamingService>();
}

Now the sixth row lands where you want it. The generated entity is Person, the DbSet is Persons, and the mapping still points at the real table:

modelBuilder.Entity<Person>(entity =>
{
    entity.ToTable("tbl_person");

    entity.Property(e => e.PersonId)
        .ValueGeneratedNever()
        .HasColumnName("person_id");
    entity.Property(e => e.FullName).HasColumnName("full_name");
});

Four ways this silently does nothing

--no-pluralize turns your service off completely. Every call site is wrapped in if (!_options.NoPluralize), so with the flag set, DbSet names equal table names and your Pluralize is never invoked. Verified: with DomainPluralizer registered and --no-pluralize passed, the context came out with DbSet<Gas> Gas, DbSet<Kunde> Kunde, DbSet<TblPerson> TblPerson.

--use-database-names does not turn it off. That flag only bypasses ICandidateNamingService; the singularizer and pluralizer still run, now on the raw database identifier. On the same schema the result was DbSet<tbl_Person> tbl_Persons, with the casing of the dictionary entry (Person) replacing the raw person. If you want raw names, you need both flags.

A pluralizer that collapses names gets uniquified behind your back. CSharpUniqueNamer does while (_usedNames.Contains(name)) name = input + suffix++;, so if two tables map to the same identifier you get Order and Order1 rather than an error. Entity type names are compared case-insensitively, DbSet names case-sensitively.

None of this affects code-first. IPluralizer is consumed by exactly one class in the entire design-time assembly, RelationalScaffoldingModelFactory. dotnet ef migrations add never touches it, and EF Core does not pluralize table names when generating a schema from your model in the first place. If what you want is to control the names EF Core writes to the database, that is a model-building convention, covered in custom naming conventions for keys, foreign keys, and indexes.

A short history, because the answers on the internet are old

IPluralizer shipped in EF Core 2.0 as a hook with no implementation behind it. As late as EF Core 3.1 the registration was still AddSingleton<IPluralizer, NullPluralizer>(), which is why so many blog posts from that era tell you to install a third-party pluralizer package just to get Customers out of a Customer table. EF Core 5.0 changed the registration to HumanizerPluralizer and took a dependency on Humanizer.Core 2.8.26, and the hook has worked the same way ever since. Anything written before 2020 that says scaffolding does not pluralize at all was true when it was written.

If your dotnet ef invocation is failing before it gets anywhere near naming, the two usual causes are a design-time DbContext that cannot be constructed and a tool version that does not match the runtime packages. On EF Core 11 specifically, the tooling also gained a single command that creates and applies a migration, and if you are arriving from an older major, the EF Core 6 to 11 breaking changes are worth a pass first.

The rule that keeps this maintainable: the pluralizer is a dictionary with a fallback, not an algorithm. Every entry you add is a name somebody on the team argued about once, written down where the next scaffold run will honour it.

Sources

Comments

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

< Back