Model and Conventions in EF Core: Keys, Nullability, Column Types and Shadow Properties
We read how EF Core builds its model from real schema output: the key convention, NOT NULL and NULL columns with NRT, SQLite and SQL Server column types, the decimal warning, temporary keys and Guid generation, where table names come from, and the shadow foreign key EF Core adds on its own.
Version note: The code in this post is written for EF Core 6 and runs unchanged on EF Core 10.
Guess First
We have three classes and a context with two DbSets. No attributes, no OnModelCreating:
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string? Subtitle { get; set; }
public decimal Price { get; set; }
public int? PageCount { get; set; }
public DateTime PublishedOn { get; set; }
public bool IsAvailable { get; set; }
public Author? Author { get; set; }
public string DisplayTitle => Subtitle is null ? Title : $"{Title}: {Subtitle}";
}
public class Tag
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
}
public class BookstoreContext : DbContext
{
public DbSet<Book> Books => Set<Book>();
public DbSet<Tag> Tags => Set<Tag>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlite("Data Source=bookstore.db")
.LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information);
}
}
How many tables does EnsureCreated create? There is no DbSet for Author; will it still get a table, and if so, what will it be called? Which columns in the Books table accept NULL? Does DisplayTitle become a column? And does a column we never wrote in Book show up in the table?
Where Does the Model Come From?
The first time a context is used, EF Core builds the model: the full map of entities, properties, keys and relationships. It starts from the DbSet properties and discovers every class it can reach by following navigations. Then it applies conventions to each of them. A convention is a rule that says "unless you tell me otherwise, this is what I assume". In this post we rely on conventions only; the ways to tell EF Core otherwise are in the next post. The model is built once per context type and cached.
db.Model.ToDebugString() is enough to see the whole model. Here is the EF Core 10 output:
Model:
EntityType: Author
Properties:
Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
Name (string) Required
Keys:
Id PK
EntityType: Book
Properties:
Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
AuthorId (no field, int?) Shadow FK Index
IsAvailable (bool) Required
PageCount (int?)
Price (decimal) Required
PublishedOn (DateTime) Required
Subtitle (string)
Title (string) Required
Navigations:
Author (Author) Optional ToPrincipal Author
Keys:
Id PK
Foreign keys:
Book {'AuthorId'} -> Author {'Id'} ClientSetNull ToPrincipal: Author
Indexes:
AuthorId
EntityType: Tag
Properties:
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
Name (string) Required
Keys:
Id PK
The EF Core 6 output is nearly identical; the only differences are that the navigation line doesn't include the word Optional and the order in the foreign key line. One of the answers is already visible here: Author made it into the model because the Book.Author navigation points to it. DisplayTitle is not in the list.
And here is the schema created in the database:
CREATE TABLE "Author" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Author" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL
);
CREATE TABLE "Tags" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY,
"Name" TEXT NOT NULL
);
CREATE TABLE "Books" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NOT NULL,
"Subtitle" TEXT NULL,
"Price" TEXT NOT NULL,
"PageCount" INTEGER NULL,
"PublishedOn" TEXT NOT NULL,
"IsAvailable" INTEGER NOT NULL,
"AuthorId" INTEGER NULL,
CONSTRAINT "FK_Books_Author_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Author" ("Id")
);
CREATE INDEX "IX_Books_AuthorId" ON "Books" ("AuthorId");
This is the SQL part of the commands in the log; EF Core 6 and 10 produced the same schema. Let's read this output one convention at a time.
The Key Convention
A property named Id or <EntityName>Id becomes the primary key. For Book, both Id and BookId work. If the key is an int or a long, EF Core marks it as "generated on add" (ValueGenerated.OnAdd) and it becomes an AUTOINCREMENT column in SQLite. The SQL Server equivalent is IDENTITY.
If EF Core can't find a key, it doesn't guess; it refuses to build the model. When we added a Review class with a property named ReviewNumber as a DbSet, we got this error:
InvalidOperationException: The entity type 'Review' requires a primary key to be defined. If you intended to use a keyless entity type, call 'HasNoKey' in 'OnModelCreating'. For more information on keyless entity types, see https://go.microsoft.com/fwlink/?linkid=2141943.
Either you rename the property to match the convention or you state the key explicitly. Keyless entities (for views and report results) are the topic of the raw SQL post.
Required and Optional with NRT
The .NET 6 templates come with <Nullable>enable</Nullable>, and EF Core uses that information. string Title became NOT NULL and string? Subtitle NULL. For value types the rule comes from C# itself: int, decimal, bool and DateTime are NOT NULL; int? is NULL.
What if nullable reference types were off? When we put the same kind of class inside a #nullable disable region, we saw this:
#nullable disable
public class LegacyBook
{
public int LegacyBookId { get; set; }
public string Title { get; set; }
}
#nullable enable
CREATE TABLE "LegacyBooks" (
"LegacyBookId" INTEGER NOT NULL CONSTRAINT "PK_LegacyBooks" PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NULL
);
Title turned into a column that accepts NULL, because with NRT off a string can always be null. When you move an old project to .NET 6 and turn Nullable on, the opposite happens: the next migration tries to turn a lot of columns into NOT NULL. LegacyBookId also became the key through the <EntityName>Id rule.
Note: NOT NULL is a database rule. When you assign null! to Title and try to save, EF Core doesn't stop you before SaveChanges; you get the error from the database: SQLite Error 19: 'NOT NULL constraint failed: Books.Title'.
Column Types and String Length
SQLite's type system is tiny: INTEGER, REAL, TEXT, BLOB. That is why in the schema above int and bool became INTEGER, while string, decimal, DateTime and Guid became TEXT. There is no length information either; TEXT has no limit in SQLite.
To see what the same model turns into on SQL Server, we can use GenerateCreateScript, which produces the schema without opening a connection. For this we wrote a second context configured with UseSqlServer and added the [MaxLength(200)] attribute, the topic of the next post, to Title:
using SqlServerBookstoreContext db = new();
Console.WriteLine(db.Database.GenerateCreateScript());
warn: 11/4/2026 00:50:20.296 SqlServerEventId.DecimalTypeDefaultWarning[30000] (Microsoft.EntityFrameworkCore.Model.Validation)
No store type was specified for the decimal property 'Price' on entity type 'Book'. This will cause values to be silently truncated if they do not fit in the default precision and scale. Explicitly specify the SQL server column type that can accommodate all the values in 'OnModelCreating' using 'HasColumnType', specify precision and scale using 'HasPrecision', or configure a value converter using 'HasConversion'.
CREATE TABLE [Books] (
[Id] int NOT NULL IDENTITY,
[Title] nvarchar(200) NOT NULL,
[Subtitle] nvarchar(max) NULL,
[Price] decimal(18,2) NOT NULL,
[PageCount] int NULL,
[PublishedOn] datetime2 NOT NULL,
[IsAvailable] bit NOT NULL,
[AuthorId] int NULL,
CONSTRAINT [PK_Books] PRIMARY KEY ([Id]),
CONSTRAINT [FK_Books_Author_AuthorId] FOREIGN KEY ([AuthorId]) REFERENCES [Author] ([Id])
);
Strings without a length became nvarchar(max), while Title, which got [MaxLength(200)], became nvarchar(200). The default decimal(18,2) was chosen for decimal, and EF Core printed a warning about it: values that don't fit this precision are silently truncated. SQLite gives no warning for the same model, because there decimal is stored as TEXT anyway. Whatever provider you use, make it a habit to state the precision of every decimal that holds money; we will see how in the next post.
Generated Values and the Temporary Key
If the database generates the key, what is the key's value right after the entity is added but before it is saved?
Author author = new() { Name = "Martin Fowler" };
Book book = new() { Title = "Refactoring", Price = 42.50m, Author = author };
db.Books.Add(book);
Console.WriteLine($"Before SaveChanges: Id = {book.Id}");
Console.WriteLine($"Temporary: {db.Entry(book).Property(b => b.Id).IsTemporary}");
Console.WriteLine($"Tracked value: {db.Entry(book).Property(b => b.Id).CurrentValue}");
db.SaveChanges();
Console.WriteLine($"After SaveChanges: Id = {book.Id}");
Tag tag = new() { Name = "classic" };
db.Tags.Add(tag);
Console.WriteLine($"Guid before SaveChanges: {tag.Id}");
db.SaveChanges();
Before SaveChanges: Id = 0
Temporary: True
Tracked value: -2147482647
After SaveChanges: Id = 1
Guid before SaveChanges: 4eb01e7a-9398-4fd0-97fb-844553ae1773
On the entity itself Id is still 0. The change tracker, however, holds a temporary negative value internally; it needs one to wire relationships together before saving. In SaveChanges, the real value returned by the INSERT replaces the temporary one and is written into the entity. A Guid key is different: EF Core generates the value itself at Add time, before anything reaches the database, and the INSERT sends the Id column as a parameter. EF Core 6 and 10 behave the same at this step.
Table and Column Names
The table name comes from the name of the DbSet property that exposes the entity: Books and Tags. Author, which has no DbSet, took the class name, the singular Author. If you see both plural and singular table names in the same database, this is most likely the reason. Column names are the same as the property names. Primary key, foreign key and index names follow a pattern too: PK_Books, FK_Books_Author_AuthorId, IX_Books_AuthorId.
DisplayTitle did not become a column because it only has a getter. By convention EF Core doesn't map properties without a setter. But if you want to exclude a writable property from mapping, you have to say so explicitly. The ways to change table and column names are in the next post as well.
A First Look at Shadow Properties
The schema has a column called Books.AuthorId, but the Book class has no such property. There is a navigation but no foreign key property; to store the relationship in the database, EF Core added the column itself. The AuthorId (no field, int?) Shadow FK Index line in the debug output says exactly that. Properties like this, which don't live in the class and exist only in the model and the change tracker, are called shadow properties.
You can't reach a shadow property through the entity, but you can through the change tracker and in queries:
Book book = db.Books.Single(b => b.Title == "Refactoring");
object? authorId = db.Entry(book).Property("AuthorId").CurrentValue;
Console.WriteLine($"AuthorId (shadow): {authorId}");
List<string> titles = db.Books
.Where(b => EF.Property<int?>(b, "AuthorId") == 1)
.Select(b => b.Title)
.ToList();
AuthorId (shadow): 1
SELECT "b"."Title"
FROM "Books" AS "b"
WHERE "b"."AuthorId" = 1
EF.Property only has meaning inside LINQ; EF Core translates it straight into the column name. Shadow properties are also handy for data you don't want cluttering your domain class, such as audit fields (CreatedAt, UpdatedBy). For foreign keys, though, writing the property explicitly is usually more convenient; we will see why in the relationships posts.
Common Pitfalls
Trusting SQLite's silence. SQLite doesn't enforce length limits and stores decimal as TEXT. If you use SQLite in development and SQL Server in production, check the real shape of the schema on the other provider too, with GenerateCreateScript or a migration script.
Mixing singular and plural table names. Adding a DbSet to an entity later changes its table name from Author to Authors. If you use migrations, that is a schema change, not a rename.
Turning NRT on without thinking about the schema. Turning Nullable on doesn't only change compiler warnings; it changes EF Core's model too. Read the first migration after turning it on carefully.
Treating the temporary key as real. Don't store the negative value you read from the change tracker after Add and use it in a URL or a log. The real value arrives after SaveChanges.
A Small Experiment
★☆☆ Add public DbSet<Author> Authors => Set<Author>(); to the context, delete the database and create it again. How did the table name and the name of the foreign key constraint change?
★★☆ Add public int AuthorId { get; set; } (not nullable) to Book. How did the AuthorId line in the debug output change? Does the AuthorId column in the schema still accept NULL? Also look at the delete behavior in the foreign key line of the debug output.
★★★ Change the key of Tag from Guid to string and try to add a tag without giving it a value. Explain the error you get in terms of what the key convention and generated values tell you.
Conventions got us this far, but in real projects they are not always enough: the precision of a decimal, the length of a string, a singular table name, a unique ISBN. In the next post we will look at how to override what conventions say: data annotations and the Fluent API, OnModelCreating, IEntityTypeConfiguration<T>, indexes, unique constraints, check constraints and default values.