LINQ to SQL as the original IQueryable provider: its architecture and lasting concepts (translation, change tracking, identity map, lazy loading, parameterisation) and how they map to EF Core today.
LINQ to SQL was Microsoft's first ORM built on IQueryable, shipped in .NET Framework 3.5 and SQL Server only. You wrote LINQ against a DataContext, and it translated the expression tree into parameterised T-SQL, tracked changes and applied them with SubmitChanges. It never came to .NET Core, so on .NET 10 you use EF Core, but the concepts carry straight over: query translation, change tracking, the identity map, lazy loading and N+1 queries, and parameterisation that prevents SQL injection.
LINQ to SQL (.NET Framework 3.5, 2007) was Microsoft's first LINQ-based ORM for SQL Server: you wrote LINQ against a DataContext, it translated expression trees to SQL, tracked changes and saved them. It was superseded by Entity Framework and isn't available on modern .NET. But almost every concept it introduced lives on in EF Core, so understanding it explains how EF Core works.
LINQ to SQL implemented IQueryable<T> with its own query provider:
Queryable.Where, Select, etc. don't execute anything — they build an expression tree (Expression<Func<T,bool>>).EF Core uses exactly this model: DbSet<T> is an IQueryable<T>, and EF Core's query pipeline translates expression trees to SQL for SQL Server, PostgreSQL, SQLite, Cosmos DB, etc. The concept that the static type decides where code runs (IQueryable → SQL; IEnumerable → memory) comes straight from this era.
DataContext was the unit of work: it held the connection, the mapping, the identity map and the change tracker. You modified objects and called SubmitChanges(), which generated INSERT/UPDATE/DELETE statements in a transaction.
EF Core's DbContext is the direct descendant:
ChangeTracker keeps original values (snapshot change tracking) and detects modifications on SaveChanges().SaveChanges batches commands in a transaction.DbContext is meant to be short-lived (one per request/unit of work) — the same lesson LINQ to SQL users learned when long-lived DataContexts grew huge and stale.Within one DataContext, querying the same row twice returned the same object instance. EF Core does the same for tracked queries: one entity instance per key per context. This ensures consistency (changes made through one reference are visible via the other) but costs memory and lookup time — hence EF Core's AsNoTracking() for read-only queries, and AsNoTrackingWithIdentityResolution() to get identity resolution without tracking.
LINQ to SQL lazy-loaded associations by default: accessing customer.Orders triggered a query. That's where the N+1 problem became widely known. DataLoadOptions.LoadWith enabled eager loading.
EF Core: lazy loading is opt-in (proxies or ILazyLoader); the default is explicit eager loading with Include/ThenInclude, explicit loading (Entry(x).Collection(...).LoadAsync()), or (best for reads) projections with Select. The industry learned from LINQ to SQL's N+1 pain.
LINQ to SQL turned captured variables into SQL parameters (@p0), preventing SQL injection and enabling query plan reuse. EF Core does the same: closure variables become parameters, constants become literals. That's why Where(o => o.CustomerId == customerId) is safe, and why string-concatenated raw SQL (FromSqlRaw($"...")) isn't. EF Core 9/10 added options for parameter handling of collections (e.g. Contains over lists translated with JSON parameters/OPENJSON or multiple parameters) to balance plan caching and performance.
System.Data.Linq doesn't exist in .NET 5+.DbContext vs DataContext, SaveChanges vs SubmitChanges, configuration via Fluent API instead of attributes/DBML).Porting a .NET Framework 4.x system that used LINQ to SQL to .NET 8 required replacing the DBML-generated classes with EF Core entities and configurations. The tricky parts were behaviours the old code relied on implicitly: lazy loading in views (which became N+1 explosions or null navigations in EF Core without lazy loading), a long-lived DataContext cached in a static (replaced by scoped DbContexts), and queries that LINQ to SQL evaluated differently. Logging EF Core SQL (LogTo) and integration tests against SQL Server made the migration safe.
Checks historical context and, more importantly, the transferable concepts behind database LINQ.
Green flags: knows it was SQL Server only and .NET Framework only; explains translation, change tracking, identity map, lazy loading and parameterisation; maps each to EF Core.
Red flags: thinks LINQ to SQL is current; cannot explain how a LINQ query becomes SQL.
C# lambda → Expression tree → Provider
LINQ to SQL: expression tree → SQL Server SQL text
EF Core: expression tree → query model (relational expressions) → provider-specific SQL → cached compiled query
EF Core adds a query cache keyed by expression tree shape: the first execution translates and compiles; later executions with different parameter values reuse it. Changing constants (rather than parameters) produces different cache entries — a reason to avoid building queries with inline constants dynamically.
INotifyPropertyChanged/proxies): entities notify the tracker of changes.ExecuteUpdate/ExecuteDelete perform set-based updates without tracking — something LINQ to SQL couldn't do natively.Reload()).AsNoTracking() creates new instances per row; AsNoTrackingWithIdentityResolution() ensures one instance per key within the query results only.LINQ to SQL (and EF Core 1–2) silently evaluated untranslatable parts in memory. EF Core 3.0 changed this: client evaluation is only allowed in the final projection; anything else untranslatable throws. Lesson learned: silent client evaluation caused huge hidden performance problems.
| LINQ to SQL | EF Core |
|---|---|
DBML designer / [Table], [Column] attributes |
Conventions, data annotations, Fluent API (IEntityTypeConfiguration<T>) |
| SQL Server only | Many providers |
| Single-table inheritance only | TPH, TPT, TPC |
| Limited type conversions | Value converters, owned/complex types, JSON columns |
SubmitChanges |
SaveChanges/SaveChangesAsync, bulk ExecuteUpdate/Delete |
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information)) and run a query with a captured variable; observe parameters.DbContext with tracking and compare references; then with AsNoTracking.Include or a projection.ExecuteUpdateAsync for a bulk status change and compare the SQL with tracked updates.// Legacy LINQ to SQL (.NET Framework 3.5-4.8 only), shown for recognition:
// [Table(Name = "Orders")] public class Order { [Column(IsPrimaryKey = true)] public int Id; ... }
// using var db = new DataContext(connectionString);
// var big = db.GetTable<Order>().Where(o => o.Total > 100).ToList();
// db.SubmitChanges();
// The same ideas in EF Core 10 on .NET 10
using Microsoft.EntityFrameworkCore;
namespace Linq.ToSqlConcepts;
public sealed class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Order> Orders { get; } = [];
}
public sealed class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
public Customer Customer { get; set; } = null!;
}
public sealed class SalesContext(DbContextOptions<SalesContext> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
}
public static class Queries
{
// Translation to parameterised SQL
public static IQueryable<Order> LargeOrders(SalesContext db, decimal minimum)
=> db.Orders.Where(o => o.Total > minimum);
// Eager projection instead of lazy loading (avoids N+1)
public static Task<List<OrderSummary>> SummariesAsync(SalesContext db, CancellationToken ct)
=> db.Orders
.AsNoTracking()
.Select(o => new OrderSummary(o.Id, o.Customer.Name, o.Total))
.ToListAsync(ct);
// Parameterised raw SQL: interpolation becomes DbParameter, not string concatenation
public static IQueryable<Customer> ByName(SalesContext db, string name)
=> db.Customers.FromSql($"SELECT * FROM Customers WHERE Name = {name}");
}
public sealed record OrderSummary(int Id, string CustomerName, decimal Total);
// Usage (for example in a test):
// Console.WriteLine(Queries.LargeOrders(db, 100m).ToQueryString());
// -- DECLARE @minimum decimal(18,2) = 100.0;
// -- SELECT ... FROM [Orders] AS [o] WHERE [o].[Total] > @minimum
What happens: candidates can't explain where EF Core's behaviours come from. Why it is wrong: misses the shared provider model, change tracking and identity map ideas. Do instead: explain the lineage and what EF Core improved.
What happens: a DbContext (like old DataContexts) kept as a singleton.
Why it is wrong: not thread-safe, grows indefinitely, returns stale tracked data.
Do instead: scoped, short-lived contexts (or IDbContextFactory for background work).
What happens: ported code expects navigation properties to load on access.
Why it is wrong: N+1 queries or null navigations in EF Core.
Do instead: explicit Include or projections.
What happens: large read queries tracked by default.
Why it is wrong: memory and CPU overhead for change tracking that's never used.
Do instead: AsNoTracking() for reads.
What happens: concatenating user input into raw SQL.
Why it is wrong: SQL injection; no plan reuse.
Do instead: LINQ or parameterised FromSql/ExecuteSql with interpolation.
What happens: untranslatable methods in Where clauses.
Why it is wrong: EF Core 3+ throws instead of evaluating in memory.
Do instead: rewrite to translatable expressions or evaluate explicitly after materialising.
What happens: trying to reference System.Data.Linq.
Why it is wrong: not available on modern .NET.
Do instead: migrate to EF Core or Dapper.
What happens: re-querying in the same context expecting fresh values.
Why it is wrong: tracked instances may not be refreshed.
Do instead: use new contexts, AsNoTracking, or Reload().
The value of this question is the concept, not the retired library. A senior can explain the provider model (expression tree in, parameterised SQL out), the unit-of-work and identity-map behaviour of a context, and the classic failure modes (N+1, hidden client evaluation, unbounded tracking). I also mention practical migration experience: modern EF Core is stricter, which is a feature, and every migrated query should be verified against the real database.
In EF Core, DbSet<T> is an IQueryable<T>, so Where, Select and so on build an expression tree, and EF Core translates it to SQL when you enumerate, caching the translation by query shape. Only what EF understands translates, like properties, operators and many string, date and EF.Functions methods. Since EF Core 3.0, client evaluation is allowed only in the final Select, so anything else untranslatable throws. For reads I use AsNoTracking or projections, AsSplitQuery when collection includes explode, and I check the SQL with ToQueryString(), logging and Query Store.
IQueryable<T> is an IEnumerable<T> that also carries an expression tree and a provider. Queryable.Where takes an Expression<Func<T,bool>> and just wraps the expression, and when you enumerate, the provider, for example EF Core, translates the whole tree, typically into SQL. IEnumerable operators take delegates and run in memory. Which one you get depends on the static type of the variable, so storing a DbSet in an IEnumerable variable quietly moves every later Where into memory. AsEnumerable() does that on purpose. I keep IQueryable inside the data layer and return materialised results.
How to tell it (STAR)
**S:** We were moving a claims platform from .NET Framework to .NET 10. **T:** I owned the data access migration. **A:** EF Core rejected queries the old LINQ to SQL provider had silently run in memory; I traced the generated SQL, rewrote the filters to translate, and added tests against a real SQL Server. **R:** Two key reports went from [time] to [time], and the migration finished without data access regressions.
Last updated 30 Oct 2026.