All articles

EF Core Code First Migrations with DataAnnotations Attributes

Shape your SQL Server schema with DataAnnotations — Table, Column, Key, Required, MaxLength, Index, Timestamp, ConcurrencyCheck — and apply it with EF Core migrations, verified against a real database

0 · log in to like, save & follow Share on LinkedIn Share on X

In the code-first workflow you write C# entity classes, and EF Core migrations create and evolve the database schema for you. DataAnnotations attributes are the simplest way to shape that schema — table names, column types, keys, lengths, indexes, and concurrency all declared right on the entity. This tutorial builds a small product catalog with annotated entities, runs real migrations against SQL Server, and shows exactly what each attribute produces in the database.

If you prefer to keep mapping out of the entity classes, the same configuration can be done with the Fluent API — and for an existing database there is the database-first approach.

EF Core code-first migrations with DataAnnotations attributes

Set up the project

Any project type works — here a console app keeps the focus on EF Core:

dotnet new console -n GeekStore.CodeFirst
cd GeekStore.CodeFirst
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install -g dotnet-ef

Microsoft.EntityFrameworkCore.Design powers the dotnet ef commands; the SQL Server package is the database provider.

The annotated entities

Two entities cover most of the attributes you will use day to day.

Entities/Category.cs:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace GeekStore.CodeFirst.Entities;

[Table("ProductCategory", Schema = "Admin")]
public class Category
{
    [Key]
    public int CategoryId { get; set; }

    [Required]
    [StringLength(50)]
    public string CategoryName { get; set; } = string.Empty;

    [StringLength(250, MinimumLength = 5)]
    public string? Description { get; set; }

    public List<Product> Products { get; set; } = [];
}

Entities/Product.cs:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace GeekStore.CodeFirst.Entities;

[Index(nameof(CategoryId), Name = "IX_Product_CategoryId")]
public class Product
{
    [Key]
    public int ProductId { get; set; }

    [Required]
    [MaxLength(100)]
    [ConcurrencyCheck]
    public string ProductName { get; set; } = string.Empty;

    [MaxLength(25)]
    public string? Sku { get; set; }

    [Column("Price", TypeName = "decimal(18,2)")]
    public decimal UnitPrice { get; set; }

    [NotMapped]
    public decimal DiscountedPrice => UnitPrice * 0.9m;

    [Timestamp]
    public byte[] RowVersion { get; set; } = [];

    [ForeignKey(nameof(Category))]
    public int CategoryId { get; set; }
    public Category? Category { get; set; }
}

And the DbContext:

using GeekStore.CodeFirst.Entities;
using Microsoft.EntityFrameworkCore;

namespace GeekStore.CodeFirst;

public class StoreContext : DbContext
{
    public DbSet<Category> Categories { get; set; }
    public DbSet<Product> Products { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlServer("<your connection string>");
}

Run the first migration

dotnet ef migrations add InitialCreate
dotnet ef database update

migrations add generates a Migrations folder with C# code describing the schema; database update executes it against the database. Checking the result in SQL Server:

TABLE_SCHEMA  TABLE_NAME
dbo           __EFMigrationsHistory
Admin         ProductCategory
dbo           Products

The __EFMigrationsHistory table is EF Core's bookkeeping — it records which migrations have been applied, so database update only ever runs the new ones.

The Products columns, exactly as the attributes declared them:

COLUMN_NAME   DATA_TYPE   MAX_LENGTH   IS_NULLABLE
ProductId     int                      NO
ProductName   nvarchar    100          NO
Sku           nvarchar    25           YES
Price         decimal                  NO
RowVersion    timestamp                NO
CategoryId    int                      NO

Note what's not there: DiscountedPrice — the [NotMapped] attribute kept the computed property out of the table.

What each attribute does

Table

By default the table is named after the DbSet property (Categories). [Table("ProductCategory", Schema = "Admin")] renames it and moves it to the Admin schema — as the migration output above shows.

Column

[Column("Price", TypeName = "decimal(18,2)")] renames UnitPrice to Price in the database and pins the SQL type. Without TypeName, decimals default to decimal(18,2) anyway, but being explicit protects you from provider-default changes. For plain precision control you can also use [Precision(18, 2)].

Key

[Key] marks the primary key. Convention already treats Id or <Entity>Id as the key, so the attribute is only required for unconventional names — but it makes intent obvious either way. For composite keys, DataAnnotations are not enough in EF Core: use the Fluent API's HasKey(p => new { p.OrderId, p.ProductId }).

Required

[Required] makes the column NOT NULL. With nullable reference types enabled, a non-nullable string property is already treated as required — the attribute makes it explicit and covers projects without NRT.

MaxLength and StringLength

Both set the column size (nvarchar(100), nvarchar(25) above). The differences:

  • MaxLength works on string and byte[]; StringLength is strings only.
  • StringLength can also declare a minimum (MinimumLength = 5) — the minimum is enforced by validation (for example in ASP.NET Core model binding), not by the database.
  • Without either, EF Core creates nvarchar(max) — worth avoiding for anything you might index or compare.

NotMapped

For computed or helper properties that should not become columns. DiscountedPrice exists on the class, never in the database.

ForeignKey

EF Core infers the Product.CategoryIdCategory relationship by convention; [ForeignKey(nameof(Category))] states it explicitly and is required when the property name doesn't follow convention. The migration created the constraint and cascade behavior automatically. Fine-grained control over delete behavior lives in the Fluent API.

Index

[Index(nameof(CategoryId), Name = "IX_Product_CategoryId")] goes on the class (not the property) and supports IsUnique = true for unique indexes. Verified in the database:

name
IX_Product_CategoryId

If you used EF6 you may remember a property-level [Index] attribute — that one does not exist in EF Core; the class-level attribute (EF Core 5+) replaced it.

Timestamp

[Timestamp] on a byte[] property creates a SQL Server rowversion column. The database changes the value automatically on every update, and EF Core includes it in the WHERE clause of updates — if another user changed the row since you read it, zero rows match and EF Core throws DbUpdateConcurrencyException. One per entity.

ConcurrencyCheck

Same optimistic-concurrency idea, but on a normal column of any type, and usable on several properties at once. With [ConcurrencyCheck] on ProductName, an update executes as:

UPDATE Products
SET ProductName = 'Product 3'
WHERE ProductId = 3 AND ProductName = 'Product 1'

If someone already renamed the product, the row count is zero and EF Core raises DbUpdateConcurrencyException — your signal to reload and retry. Use [Timestamp] when any change should conflict; use [ConcurrencyCheck] when only specific columns matter.

Evolving the schema — the second migration

Schema work is never one-and-done. Adding the Sku property later is the same two commands:

dotnet ef migrations add AddProductSku
dotnet ef database update

EF Core diffs the model against the last migration and generates only the change:

migrationBuilder.AddColumn<string>(
    name: "Sku",
    table: "Products",
    type: "nvarchar(25)",
    maxLength: 25,
    nullable: true);

Other commands you will reach for:

dotnet ef migrations remove         # undo the last (unapplied) migration
dotnet ef migrations script         # SQL script for review or DBA hand-off
dotnet ef database update 0         # revert everything

DataAnnotations or Fluent API?

DataAnnotations cover the common cases with minimal ceremony and keep the schema visible right on the entity. The Fluent API can express everything annotations can plus the rest — composite keys, cascade behavior, many-to-many configuration, value conversions. They also combine: annotations for the basics, Fluent API for the exceptions, with the Fluent API winning when both configure the same thing. The follow-up covers it: using Fluent API in EF Core code first.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.