Entity Framework Core Tutorial: Getting Started in ASP.NET Core
If you've been building .NET applications and wondering how to stop writing raw SQL everywhere, this entity framework core tutorial is the place to start.Entity Framework Core (EF Core) is Microsoft's official object-relational mapper (ORM) for .NET, and in EF Core 10 it has never been more capable or developer-friendly. It lets you work with your database using C# classes and LINQ instead of raw SQL strings, handles schema migrations for you, and integrates cleanly with ASP.NET Core's dependency injection system.
This guide will walk you through everything you need to get up and running -- picking the right NuGet packages, defining entities, configuring a DbContext, registering it with DI, running your first migration, and writing your first query. By the end you'll have a working data access layer you can build on.
Entity Framework Core Tutorial: Setup and NuGet Packages
EF Core is split into several packages. You don't install one giant library -- you install the core abstractions plus a database provider that matches your target database.
For most projects you'll need:
Microsoft.EntityFrameworkCore.SqlServer-- the SQL Server provider (targets Azure SQL and on-premises SQL Server)Microsoft.EntityFrameworkCore.Sqlite-- the SQLite provider (great for development, testing, and small apps)Microsoft.EntityFrameworkCore.Tools-- the CLI tools package (dotnet ef) for managing migrationsMicrosoft.EntityFrameworkCore.Design-- the design-time tooling required by the tools package; add this as a development dependency
A typical setup for a new project uses SQLite in development and SQL Server in production. You can swap providers simply by changing the DI registration -- the entity and context code stays the same.
To add the packages to your project:
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 10.*
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.*
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 10.*
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.*
The Microsoft.EntityFrameworkCore.Design package should typically be marked as a development-only dependency. In your .csproj, add PrivateAssets="All" to its <PackageReference> so it is not included in your published output:
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*">
<PrivateAssets>All</PrivateAssets>
</PackageReference>
Defining Your Entity Classes
An entity class is just a C# class that maps to a database table. EF Core uses conventions to figure out column names, primary keys, and relationships -- but you can override those conventions with data annotations or the Fluent API.
Here is a realistic example with two related entities: Category and Product.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MyApp.Data.Entities;
public sealed class Category
{
public int Id { get; init; }
[Required]
[MaxLength(100)]
public required string Name { get; init; }
[MaxLength(500)]
public string? Description { get; init; }
public ICollection<Product> Products { get; init; } = [];
}
public sealed class Product
{
public int Id { get; init; }
[Required]
[MaxLength(200)]
public required string Name { get; init; }
[Required]
[MaxLength(2000)]
public required string Description { get; init; }
[Column(TypeName = "decimal(18,2)")]
public decimal Price { get; init; }
public bool IsAvailable { get; init; } = true;
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
// Foreign key -- EF Core discovers this by convention
public int CategoryId { get; init; }
public Category? Category { get; init; }
}
A few things worth pointing out here:
Convention-based primary keys. A property named Id (or {TypeName}Id) is automatically treated as the primary key. No attribute needed.
Data annotations. [Required] maps to a NOT NULL column constraint. [MaxLength(200)] sets the column length and also generates a database constraint. [Column(TypeName = "decimal(18,2)")] overrides the CLR-to-SQL type mapping when the default isn't precise enough -- important for money values.
Navigation properties. The Category property on Product and the Products collection on Category tell EF Core about the one-to-many relationship. EF Core 10 can infer these automatically from foreign key conventions.
required keyword. Using C# 11+ required properties (combined with init) keeps your entities immutable at the C# level while still allowing EF Core to materialize them from query results.
Creating Your DbContext
The DbContext is the central class in any entity framework core tutorial. It represents a session with the database, tracks changes to your entities, and coordinates queries and saves. You create your own class that inherits from DbContext.
using Microsoft.EntityFrameworkCore;
using MyApp.Data.Entities;
namespace MyApp.Data;
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Category> Categories => Set<Category>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Fluent API configuration -- overrides conventions and annotations
modelBuilder.Entity<Category>(entity =>
{
entity.HasKey(c => c.Id);
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
entity.HasMany(c => c.Products)
.WithOne(p => p.Category)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(p => p.Id);
entity.Property(p => p.Price)
.HasColumnType("decimal(18,2)");
entity.HasIndex(p => p.Name);
});
}
}
The DbContextOptions<AppDbContext> parameter is injected by ASP.NET Core's DI container -- you never construct it manually. The DbSet<T> properties give you typed access to each entity collection, letting you write queries like context.Products.Where(...).
OnModelCreating is where you put configuration that cannot be expressed with attributes, or where you prefer to keep data access concerns out of your domain models. Both approaches work. Many teams use the Fluent API exclusively and keep entities clean of annotations.
Registering EF Core with DI in Program.cs
ASP.NET Core has first-class support for EF Core through its DI container. You register your DbContext in Program.cs using AddDbContext or AddDbContextFactory.
using Microsoft.EntityFrameworkCore;
using MyApp.Data;
var builder = WebApplication.CreateBuilder(args);
// Read the connection string from configuration
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
// Register with SQL Server in production
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// -- OR -- register with SQLite for development / testing
// builder.Services.AddDbContext<AppDbContext>(options =>
// options.UseSqlite(connectionString));
// Optionally add DbContextFactory if you need short-lived contexts
// (useful in Blazor Server, background services, or high-throughput APIs)
// builder.Services.AddDbContextFactory<AppDbContext>(options =>
// options.UseSqlServer(connectionString));
var app = builder.Build();
// Optionally apply pending migrations automatically on startup (dev only)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
app.Run();
AddDbContext vs AddDbContextFactory: AddDbContext registers the context with a scoped lifetime -- one instance per HTTP request, which is the right choice for most web APIs and MVC controllers. AddDbContextFactory is better when you need to create short-lived contexts explicitly, which is common in Blazor Server (where components share a long-lived scope) or in background services that need per-operation contexts.
Understanding how DI containers manage object lifetimes is useful here. If you want a deeper look at what is happening under the hood, check out how dependency injection containers use reflection internally in C#.
Connection Strings in appsettings.json
Your connection string should live in appsettings.json (or environment-specific overrides) -- never hardcoded in source:
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\mssqllocaldb;Database=MyAppDb;Trusted_Connection=True;MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*"
}
For SQLite in development, swap the connection string value:
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=myapp.db"
}
}
builder.Configuration.GetConnectionString("DefaultConnection") reads from ConnectionStrings:DefaultConnection automatically. You can override this per environment using appsettings.Development.json, environment variables, or Azure App Configuration -- the configuration system resolves them all in priority order.
Keep sensitive connection strings out of source control. In production, inject them through environment variables or a secrets manager. For local development, the .NET Secret Manager (dotnet user-secrets) is the clean option.
Running Your First Migration
Migrations are how EF Core tracks your schema over time. Each migration is a C# file that describes the diff between your current model and the database. You generate them from the CLI.
First, make sure the dotnet ef tool is installed globally (or as a local tool):
# Install as a global tool
dotnet tool install --global dotnet-ef --version 10.*
# Verify installation
dotnet ef --version
Then, from your project directory, create the initial migration and apply it:
# Add the first migration -- generates a Migrations/ folder in your project
dotnet ef migrations add InitialCreate --project src/MyApp.Data --startup-project src/MyApp.Web
# Review the generated migration file, then apply it to the database
dotnet ef database update --project src/MyApp.Data --startup-project src/MyApp.Web
The migrations add command generates three files under a Migrations/ folder:
{Timestamp}_InitialCreate.cs-- theUp()andDown()methods describing the schema change{Timestamp}_InitialCreate.Designer.cs-- EF Core's snapshot metadata (do not edit manually)AppDbContextModelSnapshot.cs-- the full model snapshot used to diff against for the next migration
After every change to your entity classes or OnModelCreating configuration, run dotnet ef migrations add {MigrationName} to capture the diff, then dotnet ef database update to apply it.
Making Your First Query and Insert
Once your database is set up, querying and saving data is straightforward. Inject AppDbContext (or IDbContextFactory<AppDbContext> if you used the factory pattern) into your service or controller:
using Microsoft.EntityFrameworkCore;
using MyApp.Data;
using MyApp.Data.Entities;
namespace MyApp.Services;
public sealed class ProductService(AppDbContext db)
{
public async Task<IReadOnlyList<Product>> GetAvailableProductsAsync(
int categoryId,
CancellationToken ct = default)
{
return await db.Products
.Where(p => p.CategoryId == categoryId && p.IsAvailable)
.OrderBy(p => p.Name)
.AsNoTracking()
.ToListAsync(ct);
}
public async Task<Product> CreateProductAsync(
string name,
string description,
decimal price,
int categoryId,
CancellationToken ct = default)
{
var product = new Product
{
Name = name,
Description = description,
Price = price,
CategoryId = categoryId,
IsAvailable = true,
CreatedAt = DateTimeOffset.UtcNow
};
db.Products.Add(product);
await db.SaveChangesAsync(ct);
return product;
}
public async Task<Product?> GetProductByIdAsync(int id, CancellationToken ct = default)
{
return await db.Products
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Id == id, ct);
}
}
Two things to notice here:
AsNoTracking() tells EF Core not to track the returned entities in the change tracker. This is a significant performance win for read-only queries -- the change tracker adds memory and CPU overhead you don't need when you are never going to call SaveChangesAsync on these results. Use it for all read-only queries.
Include() eagerly loads the related Category navigation property. Without it, p.Category would be null (EF Core 10 does not lazy load by default unless you explicitly enable it). EF Core translates this to a SQL JOIN.
For writing effective LINQ queries against EF Core, see the LINQ in C# complete guide -- it covers the full range of LINQ operators EF Core can translate to SQL. Filtering specifically is covered in detail in the LINQ filtering in C# guide.
Tips for Development vs Production Configuration
Getting the configuration story right from the start saves a lot of pain later. Here are the most important practices:
Use SQLite for development, SQL Server for production
SQLite requires zero infrastructure -- no Docker, no local SQL Server install, no connection string gymnastics. It is perfect for local development and unit/integration tests. Your appsettings.Development.json can override the connection string to point to a local SQLite file while appsettings.json carries the SQL Server string for production.
The switch is one line in Program.cs:
if (app.Environment.IsDevelopment())
{
options.UseSqlite(connectionString);
}
else
{
options.UseSqlServer(connectionString);
}
Enable sensitive data logging in development only
EF Core can log the actual SQL it generates, including parameter values. This is invaluable for debugging N+1 query problems, but you should never enable parameter logging in production (it can expose sensitive data in logs):
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlite(connectionString);
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
Pair this with structured logging to make the EF Core output readable. The complete logging guide for .NET walks through the logging infrastructure in detail. If you are using Serilog (highly recommended), the step-by-step Serilog setup guide for ASP.NET Core shows you how to route EF Core's log output into structured log sinks.
Never call Database.EnsureCreated() in production
EnsureCreated() creates the database schema directly from your model -- it bypasses the migrations system entirely. This means your migration history is out of sync, and you will have a bad time when you try to add your first real migration. Use Database.MigrateAsync() to apply pending migrations, and only call that automatically in development or staging environments.
Keep your DbContext lean
A common mistake is cramming business logic into the DbContext. Keep it as a thin data access layer. Use services or repositories as the layer above it. If your application is growing toward a modular architecture, the modular monolith in C# guide shows how to structure EF Core contexts across feature modules cleanly.
Watch for N+1 queries
EF Core will not lazy load navigation properties by default. If you iterate over a list of Product entities and access product.Category inside the loop without having used Include(), you will fire one database query per product. Always think about what related data you need and load it up front with Include() or project it with Select().
FAQ
What is Entity Framework Core and how is it different from classic Entity Framework?
Entity Framework Core is a full rewrite of the original Entity Framework (EF6) targeting .NET Core and .NET 5+. It is cross-platform, significantly faster, and has a smaller footprint. It dropped some features (like lazy loading by default and some legacy mapping patterns) in favor of being lightweight and extensible. EF Core 10 runs on .NET 10 and is the actively maintained version -- EF6 is in maintenance-only mode.
Do I need to use migrations or can I just use EnsureCreated()?
For production apps, always use migrations. EnsureCreated() creates the schema once from your current model, with no history and no path to evolve it. Migrations give you a versioned, repeatable upgrade path from any schema version to any other. The only situation where EnsureCreated() is reasonable is in automated integration tests that spin up a fresh in-memory or SQLite database per test run.
Should I use AddDbContext or AddDbContextFactory?
Use AddDbContext for standard MVC controllers and minimal API handlers -- the scoped lifetime maps naturally to one context per HTTP request. Use AddDbContextFactory when you need to create and dispose contexts manually, which is the right pattern for Blazor Server components (they live longer than a single request), background hosted services, or parallel operations that need independent transaction scopes.
How do I handle database connection strings securely?
In development, use the .NET Secret Manager (dotnet user-secrets set "ConnectionStrings:DefaultConnection" "...") to keep credentials out of source control. In production, inject them through environment variables, Azure Key Vault references in App Configuration, or your hosting platform's secrets mechanism. The appsettings.json in source control should contain only placeholder values or a safe local default like a SQLite path.
Can I use EF Core with multiple databases in the same application?
Yes. Register multiple DbContext types, each with its own DbContextOptions<T> and connection string. EF Core keeps them completely separate. This is one of the core patterns in a modular monolith -- each module owns its own context and schema, and they do not share tables. See the modular monolith architecture guide for how to structure this cleanly.
What is the difference between SaveChanges and SaveChangesAsync?
SaveChanges is the synchronous version; SaveChangesAsync is the async version. In ASP.NET Core you should always use SaveChangesAsync and pass a CancellationToken so the operation can be cancelled if the HTTP request is aborted. Using the synchronous version in an async web application ties up a thread-pool thread while the database is doing I/O, reducing throughput under load.
How do I write unit tests for code that uses a DbContext?
The recommended approach for integration testing with EF Core is to use the SQLite in-memory provider (or the EF Core in-memory provider for pure unit tests with no SQL). Create a DbContextOptions pointing at a named in-memory SQLite database, call EnsureCreated() to build the schema, seed your test data, and run your service/repository against it. This gives you real SQL semantics without needing a running database server. This is exactly the pattern used in this blog's own test suite.
Wrapping Up
Entity Framework Core 10 gives you a clean, strongly typed way to talk to your database without writing raw SQL for every operation. This entity framework core tutorial has walked you through picking the right packages, defining entities with data annotations, wiring up a DbContext, registering it with ASP.NET Core's DI container, managing your schema with migrations, and writing your first queries.
The patterns here -- SQLite in development, SQL Server in production, AsNoTracking() for reads, Include() for eager loading, and proper connection string management -- form a solid foundation for any production application.
From here, the natural next steps are learning how to write more complex LINQ queries (the LINQ complete guide is a great reference) and understanding how to structure your data access layer as your application grows. Every entity framework core tutorial worth its salt stresses that EF Core rewards thoughtful design early -- a little investment in understanding the fundamentals now pays back significantly as the codebase scales.

