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.

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:
MaxLengthworks onstringandbyte[];StringLengthis strings only.StringLengthcan 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.CategoryId → Category 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.
Comments (0)
No comments yet — be the first to share your thoughts.