EF Core in Practice: From Setup to CRUD with PostgreSQL
A step-by-step guide covering modeling, migrations, queries, and the essential concepts to get started with Entity Framework Core.
Entity Framework Core is the standard ORM in the .NET ecosystem. This guide covers the fundamentals you need to work with EF Core day-to-day, following a practical order: configure the project, model your entities, create the database, operate and query data.
1. Initial Setup
Packages
You need at least two NuGet packages: the PostgreSQL provider and the design package for migrations.
# PostgreSQL provider
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
# Tooling for migrations
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-efDbContext
The DbContext is the central piece of EF Core. It represents a session with the database and is how you query and save data.
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
}Each DbSet<T> maps to a database table. EF Core uses the property name as the table name and infers columns from the entity's properties.
Connection String and Dependency Injection
In your ASP.NET Core Program.cs:
var connectionString = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("Connection string 'Default' not found.");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));And in appsettings.json:
{
"ConnectionStrings": {
"Default": "Host=localhost;Database=MyApp;Username=postgres;Password=postgres"
}
}AddDbContext registers the context as scoped in the DI container. Each HTTP request gets its own DbContext instance.
2. Modeling
Entities
An entity is a C# class that maps to a table. A property named Id or {ClassName}Id becomes the primary key automatically.
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public List<Product> Products { get; set; } = [];
}Data Annotations vs Fluent API
You have two ways to configure mapping. Data Annotations go directly on the entity:
public class Product
{
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal Price { get; set; }
[MaxLength(1000)]
public string? Description { get; set; }
}Fluent API lives in the DbContext, separating configuration from the entity:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.Property(p => p.Name)
.IsRequired()
.HasMaxLength(200);
entity.Property(p => p.Price)
.HasColumnType("decimal(18,2)");
entity.Property(p => p.Description)
.HasMaxLength(1000);
});
}Data Annotations are good for simple configurations and double as validation attributes (ASP.NET Model Validation). Fluent API is more powerful β composite keys, indexes, and relationship configuration only work via Fluent API. In larger projects, Fluent API is the standard because it keeps entities clean.
IEntityTypeConfiguration
When OnModelCreating gets large, extract each entity into its own configuration class:
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Property(p => p.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(p => p.Price)
.HasColumnType("decimal(18,2)");
builder.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId);
}
}And apply them all at once:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}ApplyConfigurationsFromAssembly automatically finds all classes implementing IEntityTypeConfiguration<T> in the assembly. This is the recommended pattern.
Relationships
One-to-Many (1:N)
The most common relationship. A Category has many Products.
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public List<Product> Products { get; set; } = [];
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
}EF Core detects this relationship automatically: CategoryId + Category navigation property. To configure it explicitly:
modelBuilder.Entity<Product>()
.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId);One-to-One (1:1)
public class User
{
public int Id { get; set; }
public string Email { get; set; }
public UserProfile? Profile { get; set; }
}
public class UserProfile
{
public int Id { get; set; }
public string Bio { get; set; }
public string AvatarUrl { get; set; }
public int UserId { get; set; }
public User User { get; set; }
}In 1:1 you need to explicitly tell which side holds the FK:
modelBuilder.Entity<User>()
.HasOne(u => u.Profile)
.WithOne(p => p.User)
.HasForeignKey<UserProfile>(p => p.UserId);Many-to-Many (N:N)
Starting with EF Core 5, N:N works without an explicit join entity:
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
public List<Course> Courses { get; set; } = [];
}
public class Course
{
public int Id { get; set; }
public string Title { get; set; }
public List<Student> Students { get; set; } = [];
}EF Core creates the join table automatically. If you need extra properties on the join (e.g., enrollment date), create the entity explicitly:
public class Enrollment
{
public int StudentId { get; set; }
public Student Student { get; set; }
public int CourseId { get; set; }
public Course Course { get; set; }
public DateTime EnrolledAt { get; set; }
}modelBuilder.Entity<Enrollment>()
.HasKey(e => new { e.StudentId, e.CourseId });
modelBuilder.Entity<Enrollment>()
.HasOne(e => e.Student)
.WithMany(s => s.Enrollments)
.HasForeignKey(e => e.StudentId);
modelBuilder.Entity<Enrollment>()
.HasOne(e => e.Course)
.WithMany(c => c.Enrollments)
.HasForeignKey(e => e.CourseId);3. Migrations
Migrations are how EF Core versions your database schema. Each migration represents the changes that need to be applied.
# Create a migration
dotnet ef migrations add InitialCreate
# Apply to database
dotnet ef database update
# Remove last migration (if not yet applied)
dotnet ef migrations removeWhen you run migrations add, EF Core compares the current model (your entities and configurations) with the last snapshot and generates the C# code needed for the transition.
A migration generates two methods β Up applies the change, Down reverts it:
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(maxLength: 200, nullable: false),
Price = table.Column<decimal>(type: "numeric(18,2)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Products");
}
}Always review the generated code before applying β EF Core doesn't always infer the correct intent, especially with column renames (it may generate a drop + create instead of a rename).
4. CRUD
Insert
var product = new Product { Name = "Laptop", Price = 4999.99m, CategoryId = 1 };
context.Products.Add(product);
await context.SaveChangesAsync();
// product.Id now has the database-generated valueRead
var product = await context.Products.FindAsync(1);FindAsync is optimized: it first checks the change tracker (already loaded entities), only hitting the database if necessary.
Update
var product = await context.Products.FindAsync(1);
product!.Price = 3999.99m;
await context.SaveChangesAsync();EF Core automatically detects that Price changed and generates the UPDATE for that column only.
Delete
var product = await context.Products.FindAsync(1);
context.Products.Remove(product!);
await context.SaveChangesAsync();Inserting Graphs
When you add an entity with populated navigation properties, EF Core inserts the entire graph:
var category = new Category
{
Name = "Electronics",
Products =
[
new Product { Name = "Laptop", Price = 4999.99m },
new Product { Name = "Mouse", Price = 89.99m }
]
};
context.Categories.Add(category);
await context.SaveChangesAsync();
// Inserts the Category AND both Products with correct FKs5. Queries
EF Core translates LINQ expressions to SQL. Understanding this translation is key to avoiding inefficient queries.
Filters and Ordering
var expensiveProducts = await context.Products
.Where(p => p.Price > 100)
.OrderBy(p => p.Name)
.ThenByDescending(p => p.Price)
.ToListAsync();First and Single
var product = await context.Products
.FirstOrDefaultAsync(p => p.Id == 1);
// FirstOrDefault: returns null if not found
// SingleOrDefault: returns null if not found, EXCEPTION if more than one foundProjections (Select)
Projections bring only the fields you need, instead of the entire entity:
var products = await context.Products
.Where(p => p.Price > 50)
.Select(p => new
{
p.Name,
p.Price,
CategoryName = p.Category.Name
})
.ToListAsync();EF Core generates SQL that brings only the selected columns. Projections are one of the most efficient ways to query data.
Eager Loading (Include)
When you need the full entity along with its relationships:
var products = await context.Products
.Include(p => p.Category)
.ToListAsync();
// Nested levels
var categories = await context.Categories
.Include(c => c.Products)
.ThenInclude(p => p.Tags)
.ToListAsync();Include generates JOINs in SQL. Each Include adds more data to the result β use it consciously.
Pagination
var page = 1;
var pageSize = 20;
var products = await context.Products
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();Always use OrderBy before Skip/Take. Without ordering, the result is non-deterministic.
6. DbContext Lifecycle and AsNoTracking
Lifecycle
The DbContext is registered as scoped in the DI container. This means each HTTP request gets its own instance β and that's intentional.
The DbContext is not thread-safe and should not be shared across requests. It tracks all entities that pass through it, so long lifetimes accumulate entities in memory and slow everything down. Use short lifetimes: scoped per request in web APIs.
AsNoTracking
By default, every query tracks the returned entities to detect changes on SaveChanges. When you only need to read data without modifying it, use AsNoTracking:
var products = await context.Products
.AsNoTracking()
.Where(p => p.Price > 100)
.ToListAsync();Without tracking, EF Core doesn't store entity snapshots, reducing memory consumption. In read-only scenarios (dashboards, reports, APIs that only return data), AsNoTracking should be the default.
7. Seeding
Data seeding lets you populate the database with initial data through migrations. Use HasData in OnModelCreating:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Category>().HasData(
new Category { Id = 1, Name = "Electronics" },
new Category { Id = 2, Name = "Books" },
new Category { Id = 3, Name = "Clothing" }
);
modelBuilder.Entity<Product>().HasData(
new Product { Id = 1, Name = "Laptop", Price = 4999.99m, CategoryId = 1 },
new Product { Id = 2, Name = "Clean Code", Price = 89.99m, CategoryId = 2 }
);
}With HasData, you must provide the Id explicitly β EF Core doesn't auto-generate values for seeds. After configuring, create a migration to apply:
dotnet ef migrations add SeedInitialData
dotnet ef database updateEF Core generates INSERTs in the migration. If you change the data in HasData, the next migration generates the necessary UPDATEs or DELETEs to keep the database in sync.