Updated on
A unique constraint in EF Core is a unique index on our model: we declare it with the [Index] attribute or with HasIndex().IsUnique(), and the database refuses the second row that repeats the value.
Indexes allow duplicates until we say otherwise, which is the one thing worth knowing before writing either form. The same two options cover a single property and a combination of properties, and both produce the same object in the database.
Minimal API Setup
For this article, we’ll create a mini-version of our solar system contained in a minimal API:
app.MapPost("/planets", async (Planet planet, SolarSystemDbContext context) =>
{
try
{
context.Planets.Add(planet);
await context.SaveChangesAsync();
return Results.Created($"/planets/{planet.Id}", planet);
}
catch (DbUpdateException)
{
return Results.BadRequest();
}
})
.WithName("AddPlanet");
We have one POST endpoint that will return 201 Created if the planet was successfully added to our database, or 400 Bad Request if the database rejected the save. Catching DbUpdateException rather than everything keeps the endpoint answering for the failure this article is about, which is a second planet arriving with a name the table already holds. DbUpdateException lives in the Microsoft.EntityFrameworkCore namespace, so Program.cs needs that using directive.
Our API uses SQL Server as a database through the Microsoft.EntityFrameworkCore.SqlServer package (dotnet add package Microsoft.EntityFrameworkCore.SqlServer), so we’ll also use Docker to host it:
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=yourStrong(!)Password" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest
Using the docker run command we start a new SQL Server container, which is also how our test project gets a database. There is a full walkthrough of running SQL Server in a container from our tests if we want to go further with it. In bash or zsh, we put MSSQL_SA_PASSWORD=yourStrong(!)Password in single quotes instead, since an ! inside double quotes can start a history expansion there and stop the command with event not found.
We use a real relational database because the in-memory provider does not create database indexes, so a unique index is not enforced there. Microsoft discourages it as a test double for exactly this class of difference.
The endpoint gets SolarSystemDbContext from dependency injection, so the context takes its options through its constructor and exposes our planets as a DbSet:
using Microsoft.EntityFrameworkCore;
public class SolarSystemDbContext(DbContextOptions<SolarSystemDbContext> options)
: DbContext(options)
{
public DbSet<Planet> Planets { get; set; }
}
We register it in Program.cs, before builder.Build(), and read its connection string from configuration:
builder.Services.AddDbContext<SolarSystemDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("SolarSystemDatabase")));
The connection string goes in appsettings.json and points at our container. TrustServerCertificate=True is there because the SQL Server client encrypts by default and refuses a server certificate our machine does not trust, such as the container’s:
"ConnectionStrings": {
"SolarSystemDatabase": "Server=127.0.0.1,1433;Database=SolarSystem;User Id=sa;Password=yourStrong(!)Password;TrustServerCertificate=True"
}
The default is worth knowing before we write either form: the EF Core documentation on index uniqueness states that an index allows duplicate values until we mark it unique.
Next, we’ll see how we can add unique constraints to our planet’s Name property.
What Is a Unique Constraint in EF Core?
A unique constraint stops two rows from holding the same value in a column, and in EF Core we configure it as a unique index on the model.
An index allows duplicates until we say otherwise. Microsoft Learn puts it plainly: “By default, indexes aren’t unique: multiple rows are allowed to have the same value(s) for the index’s column set.” Marking the index unique is what turns it into a constraint.
Two ways exist and they produce the same database object. The [Index] attribute goes on the entity class with IsUnique = true. The Fluent API calls HasIndex() and then IsUnique(), either in a configuration class or directly in OnModelCreating().
The database enforces the rule, not our code. Once the unique index exists, the second insert carrying a duplicate fails when we call SaveChangesAsync(), which is why the guarantee still holds for two requests arriving at the same moment.
How Do We Add a Unique Constraint With the Index Attribute?
The [Index] attribute goes on the entity class rather than on the property, because one index can cover several properties.
Inside it, nameof(Name) names the property to index and IsUnique = true makes that index a constraint. Using nameof instead of a string literal keeps the configuration tied to the property, so a rename is caught at compile time instead of when EF Core builds the model.
The attribute lives in the Microsoft.EntityFrameworkCore namespace, not in System.ComponentModel.DataAnnotations where [Key] and [Required] live, so the file needs that using directive.
We leave the primary key alone. A property named Id is already unique by convention, and marking it required forces every request body to carry a value the database is there to generate.
The attribute is the shorter form and it covers most models. What it cannot express includes a filtered index and included columns, and that is where the Fluent API earns its place.
Let’s decorate the entity class with it:
[Index(nameof(Name), IsUnique = true)]
public class Planet
{
public int Id { get; set; }
public required string Name { get; set; }
public required double Mass { get; set; }
public required double Radius { get; set; }
public required double OrbitalPeriod { get; set; }
}
Specifically, we use the Index attribute to decorate our Planet class. We pass two things to the attribute. First is the name of the property we want to create an index for. Meanwhile the second sets the index’s IsUnique property to true. In this way, we ensure that we won’t have more than one planet with the same name.
How Do We Add a Unique Constraint With the Fluent API?
The Fluent API keeps the configuration in a class instead of an attribute, which leaves the entity free of persistence details.
PlanetConfiguration implements IEntityTypeConfiguration<Planet>, and inside Configure() the call builder.HasIndex(p => p.Name).IsUnique() creates the same unique index the attribute creates.
Nothing takes effect until the configuration is discovered. ApplyConfigurationsFromAssembly(GetType().Assembly) inside OnModelCreating() finds every IEntityTypeConfiguration<T> in the assembly, so a new configuration class needs no registration of its own.
Writing the same two calls directly in OnModelCreating() behaves identically. Separate classes only keep a context readable once a model holds more than a handful of entities.
The index still has to reach the database. A model change is not a schema change: adding and applying a migration after this edit is what runs the CREATE UNIQUE INDEX statement, and a model with no matching migration leaves the constraint in our code and missing from the table.
Let’s write that configuration:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class PlanetConfiguration : IEntityTypeConfiguration<Planet>
{
public void Configure(EntityTypeBuilder<Planet> builder)
{
builder.HasIndex(p => p.Name)
.IsUnique();
}
}
Initially, we create a PlanetConfiguration class that implements the IEntityTypeConfiguration<TEntity> interface, which has a Configure() method. Inside it, we use the HasIndex() method with which we specify which property we want for an index. Then we use the IsUnique() method to tell EF Core that we also want the index to be a unique one.
The step that puts the index in the database is adding a migration and applying it, which is worth doing as soon as the configuration is in place, after the final step below.
There is one final thing we need to do:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
Here, in our SolarSystemDbContext class, we override the OnModelCreating() method. In this method, we use the ModelBuilder‘s ApplyConfigurationsFromAssembly() method to apply all configurations from the current assembly.
Note that we can have the configuration inside the OnModelCreating() method and not in a separate class, but by extracting configurations in separate classes we achieve better readability. EF Core offers the same choice of conventions, attributes and the Fluent API for relationships.
How Do We Make a Combination of Properties Unique?
A composite unique index makes the combination unique, not either property on its own. Two planets may share a name and two may share an orbital period, but no two may share both.
The attribute takes several property names and the Fluent API takes an anonymous type holding the properties. Both add IsUnique, and both produce one index over two columns.
Column order matters. A composite index speeds up queries that filter on its columns and also queries that filter on only the first columns it covers, so the property we filter by most often belongs first.
Either property on its own can still repeat, and that is the part that surprises people. If Name has to be unique by itself as well, that is a second, single column unique index, and the composite one does not provide it.
Creating Composite Indexes in EF Core Using Attributes
Attributes are a powerful tool that can help us set composite indexes:
[Index(nameof(Name), nameof(OrbitalPeriod), IsUnique = true)]
public class Planet
{
public int Id { get; set; }
public required string Name { get; set; }
public required double Mass { get; set; }
public required double Radius { get; set; }
public required double OrbitalPeriod { get; set; }
}
Using the Index attribute, we use nameof operator to specify which properties should be included in the index. In our case, those are the Name and OrbitalPeriod. Consequently, by using this approach we ensure that there will be at most one planet with the same values for those properties.
Creating Composite Indexes in EF Core Using Fluent API
We can achieve the same result with the Fluent API as well:
public class PlanetConfiguration : IEntityTypeConfiguration<Planet>
{
public void Configure(EntityTypeBuilder<Planet> builder)
{
builder.HasIndex(p => new
{
p.Name,
p.OrbitalPeriod
})
.IsUnique();
}
}
We update the Configure() method in our PlanetConfiguration class, creating a new anonymous type that holds the properties we want to create an index for. This is the only thing we need to do to create unique composite indexes, apart from adding and applying a new migration.
Every attribute option has a Fluent API equivalent, and a few of the Fluent API options have no attribute at all:
| What we want | Data annotation | Fluent API |
|---|---|---|
| An index on one property | [Index(nameof(Name))] | HasIndex(p => p.Name) |
| Make that index unique | [Index(nameof(Name), IsUnique = true)] | HasIndex(p => p.Name).IsUnique() |
| An index over several properties | [Index(nameof(Name), nameof(OrbitalPeriod))] | HasIndex(p => new { p.Name, p.OrbitalPeriod }) |
| Choose the name of the database index | [Index(nameof(Name), Name = "Index_Name")] | HasIndex(p => p.Name).HasDatabaseName("Index_Name") |
| Sort every column descending | [Index(nameof(Name), nameof(Mass), AllDescending = true)] | HasIndex(p => new { p.Name, p.Mass }).IsDescending() |
| Sort column by column | [Index(nameof(Name), nameof(Mass), IsDescending = new[] { false, true })] | HasIndex(p => new { p.Name, p.Mass }).IsDescending(false, true) |
| Index only some rows | not available | HasIndex(p => p.Name).HasFilter("[Name] IS NOT NULL") |
| Let a nullable column repeat null, or not | not available | HasIndex(p => p.Name).IsUnique().HasFilter(null) |
| Carry extra columns in the index | not available | HasIndex(p => p.Name).IncludeProperties(p => new { p.Mass }) |
| Two indexes over the same properties | [Index(nameof(Name), Name = "IX_Second")] | HasIndex(p => new { p.Name }, "IX_Second") |
What Is the Difference Between a Unique Index and a Unique Constraint in EF Core?
In a relational database both give the same guarantee, and EF Core models that guarantee as a unique index. Microsoft Learn is direct about which one to reach for: “If you just want to enforce uniqueness on a column, define a unique index rather than an alternate key.”
HasAlternateKey() is the other option and it exists for a different job. An alternate key is a unique index plus semantics: EF treats it as read only, and a foreign key can point at it. We reach for it when another entity references the property, not merely to forbid duplicates.
Nulls are where expectations break. On SQL Server, EF adds an IS NOT NULL filter to a unique index over a nullable column, so several rows may hold null without colliding. Passing HasFilter(null) removes that filter and makes null behave like any other value.
Worth following in the diagram below: the two entry points converge on one database object, and everything after that point is the database’s answer rather than our code’s.

Which of the two forms we write is a style question, and the rest of the practices we follow when configuring a model applies to both the same way. Microsoft Learn prefers the unique index in its guidance on alternate keys.
Conclusion
In this article, we explored two approaches for applying unique constraints to properties in EF Core using the code-first approach. By using either attributes or the EF’s Fluent API, we can ensure the uniqueness of properties in our databases. Regardless of whether we opt for the simplicity of attributes or the intuitiveness of the Fluent API, these techniques help in effectively modeling our data classes.
Tested with .NET 10.0.10 and EF Core 10.0.12.
