EF Core vs redb: an object graph against 54 tables and Include
One employee from our examples takes 62 SQL queries and almost two seconds to read in EF Core. In redb Pro the same employee, from the same data and in the same shape, takes 131 ms and nine queries, and 36 ms and five on the repeat run. The difference is not that one engine is "fast" and the other "slow". It is how many tables have to be put back together to get one business object out of the database.
Below: what exactly we compared, why a graph is expensive in normal form, how redb Pro learned to read it without folding everything, and where EF Core is simply faster. There are quite a few such places, and they are in the tables too.
The object under test
Every measurement uses one model from the examples project. Shortened, it looks like this (the full class is in the repository, redb.Examples/Models/ExampleModels.cs):
[RedbScheme("Employee")]
public class EmployeeProps
{
public string FirstName { get; set; } = string.Empty;
public int Age { get; set; }
public decimal Salary { get; set; }
public string Department { get; set; } = string.Empty;
public string[]? Skills { get; set; }
public int[]? SkillLevels { get; set; }
public Address? HomeAddress { get; set; } // Address -> BuildingInfo -> arrays
public Address? WorkAddress { get; set; }
public Contact[]? Contacts { get; set; } // Contact -> ContactDetail[]
public RedbObject<ProjectMetricsProps>? CurrentProject { get; set; }
public Dictionary<string, Address>? OfficeLocations { get; set; }
public Dictionary<int, decimal>? BonusByYear { get; set; }
public Dictionary<(int Year, string Quarter), string>? PerformanceReviews { get; set; }
}
A typical business object: a few scalar fields, three addresses with a nested building, an array of contacts with metadata, dictionaries, references to other objects. For redb it is one class with an attribute. The database scheme is created from it at start-up, with no migrations.
The same object for EF Core
To keep the comparison fair, EF Core got the same model in normal form, with no JSON columns. We did not build it by hand but with our own tool: the redb flat command of redb.CLI unfolds redb classes into relational tables.
redb flat -a redb.Examples.dll -tp postgres --ddl-only
Without --ddl-only the same command also copies the data from a redb database into the flat tables. That is useful outside benchmarks as well, whenever analysts or reports need a plain relational copy.
The result is 54 tables and 59 foreign keys: a table for every nested class, every array and every dictionary, and a collection inside a collection element goes one level further down. The EF classes were generated by the standard dotnet ef dbcontext scaffold and left untouched.
Next comes what an EF developer writes by hand. Reading an employee as a whole takes 31 Include calls (excerpt):
public static IQueryable<EmployeeProps> WithGraph(this IQueryable<EmployeeProps> query) => query
.Include(e => e.objects)
.Include(e => e.EmployeeProps_Skills)
.Include(e => e.EmployeeProps_HomeAddress!)
.ThenInclude(a => a.EmployeeProps_HomeAddress_Building!)
.ThenInclude(b => b.EmployeeProps_HomeAddress_Building_Amenities)
.Include(e => e.EmployeeProps_Contacts)
.ThenInclude(c => c.EmployeeProps_Contacts_Metadata)
// ... 25 more chains
.AsSplitQuery();
AsSplitQuery is mandatory here. One query joining 30 independent collections would multiply rows: skills by contacts, contacts by offices and so on. With splitting, EF sends one query per collection, hence 31 queries for every graph read. Why Include turns into a discipline of its own in large models is covered on our Why redb page.
One more thing: EF returns entities of 54 tables, while the application needs the business class. So there has to be a mapping that assembles EmployeeProps from those entities. redb returns RedbObject<EmployeeProps> directly, with no layer in between.
How much code the EF side needed to return the same object:
| What | Lines |
|---|---|
| Entity classes from scaffold, 54 files | 1,124 |
DbContext |
930 |
Include graph and mapping of the employee to the business class |
about 360 |
The mapping time is part of the EF measurement, because we compare the path to one and the same result: a ready business object. For a business, something else matters more. Every model change in EF means a migration and edits to entities, Include chains and the mapping. In redb only the class changes.
How redb stores a graph
redb has no table per class. Objects live in one table and field values in another, one row per field. A nested class, an array element or a dictionary entry is also a value row linked to its parent. A graph of any depth is stored in the same two tables.
That has a simple consequence for reads. To load N objects as a whole, redb Pro reads the object rows and then all values of all N objects in one query; if values point at list items, one more query fetches them. Nested references to other objects add one such round per level of depth. The query count follows reference depth, not the number of fields or collections. Scheme and type metadata are checked alongside, and with the props cache on, objects already in the cache get a hash check instead of a values read. The props tree is built in memory. So an employee with 30 collections takes nine queries on the first run and five on the repeat in our runs, never thirty-one.
Loading objects from a ready list of ids is the easy part, though. The hard part is finding that list: filtering, sorting and taking a page by fields that are stored as rows.
Before: fold everything, then filter
A LINQ query over fields used to rebuild the object with a "pivot". It read all value rows of the fields involved across the whole scheme, folded them with GROUP BY per object, and only then applied the filter, the sort and the page.
The cost of such a query depended on the size of the scheme, not on the answer. Finding one employee or none cost the same: the database folded everything first. Version 3.7.1 added a prefilter that narrows the rows before folding (it has its own article). It helped wherever the condition could be expressed at the level of a value row, but the folding stayed.
After: streams
redb Pro 4.3.0 added a new query form that we call "streams" internally. The objects of the scheme now drive the query, and each field joins the value table with its own LEFT JOIN. Nothing is folded. The filter, the sort and the page sit next to the joins, and the server can stop once the page is full.
Here is how it looks for a query with three conditions, sorted by salary, with a page at offset 9000. The SQL is simplified: parameters and the trash check are removed, the shape is the real one.
Before:
WITH pvt_cte AS (
SELECT v._id_object,
(array_agg(v._string) FILTER (WHERE v._id_structure = 1000019 AND v._array_index IS NULL))[1] AS "Position",
(array_agg(v._long) FILTER (WHERE v._id_structure = 1000017 AND v._array_index IS NULL))[1] AS "Age",
(array_agg(v._numeric) FILTER (WHERE v._id_structure = 1000020 AND v._array_index IS NULL))[1] AS "Salary"
FROM _values v
WHERE v._id_structure = ANY(ARRAY[1000019,1000017,1000020])
AND v._id_object IN (SELECT _id FROM _objects WHERE _id_scheme = 1000014)
GROUP BY v._id_object)
SELECT o.* FROM pvt_cte JOIN _objects o ON o._id = pvt_cte._id_object
WHERE "Position" <> '' AND "Age" >= 30 AND "Salary" > 70000
ORDER BY "Salary" DESC LIMIT 100 OFFSET 9000;
After:
WITH pvt_cte AS (
SELECT pvt._id_object, pvt."Position", pvt."Age", pvt."Salary" FROM (
SELECT o_src._id AS _id_object, f1._string AS "Position", f2._long AS "Age", f3._numeric AS "Salary"
FROM _objects o_src
LEFT JOIN _values f1 ON f1._id_object = o_src._id AND f1._id_structure = 1000019 AND f1._array_index IS NULL
LEFT JOIN _values f2 ON f2._id_object = o_src._id AND f2._id_structure = 1000017 AND f2._array_index IS NULL
LEFT JOIN _values f3 ON f3._id_object = o_src._id AND f3._id_structure = 1000020 AND f3._array_index IS NULL
WHERE o_src._id_scheme = 1000014
OFFSET 0) pvt
WHERE pvt."Position" <> '' AND pvt."Age" >= 30 AND pvt."Salary" > 70000
ORDER BY pvt."Salary" DESC, pvt._id_object DESC LIMIT 100 OFFSET 9000)
SELECT o.* FROM pvt_cte JOIN _objects o ON o._id = pvt_cte._id_object AND o._id_scheme = 1000014
ORDER BY pvt_cte."Salary" DESC, pvt_cte._id_object DESC;
The first plan has a 26 MB hash aggregate, a hash join and about 38 thousand pages read. The second has three merges (Merge Left Join) over an index where a field's rows are already ordered by object: no hashing, no sorting, about 7,500 pages, and memory does not grow with the scheme. On a database of 200k objects this page went from 702-740 ms to 362 ms; from a cold disk, 1.8-2.3 s against 0.6-0.8 s.
The wide row is the same as before, so OR, NOT and arithmetic between fields work unchanged. The OFFSET 0 keeps PostgreSQL from pushing the filter down into the streams: in a value table the columns are correlated, while the statistics treat them as independent and misjudge row counts by tens of times.
Streams read fields that have one value per object: plain fields, dictionary entries and fields of classes inside them, list items. On PostgreSQL and SQL Server this works with or without a page; on SQLite a plain list still needs Take. Arrays, DISTINCT and a sort computed by an expression over a base field stay on the old form.
Indexes: column order decides which question an index answers
The most useful finding of this work was about indexes, not query shape. In the stock schema the value indexes were built as "structure, object, value". Such an index answers "what is this object's value", but not "which objects have this value". A filter needs the second.
In 4.4.0 all three databases got "structure, value, object" indexes. They give two things:
- A narrow condition drives the query. When the filter has a selective condition (equality,
IN, a closed range), the query starts from the value index and the other fields join to the objects it found. Finding one order number among 689k records on a copy of a production database went from 2.4 s to 30 ms. - The leading form for pages. A page sorted by a field walks that field's index in page order, reads the other fields point by point and stops at the hundredth row. The first page of the three-condition query went from 229 ms to 2 ms. This is in PostgreSQL Pro and SQLite Pro.
What streams brought on three databases
Measured on the examples database (100k employees, 8.4 million value rows as of October 12-13, 2026), warm cache, times in milliseconds.
PostgreSQL:
| Query | Pivot | Streams | Streams with new indexes |
|---|---|---|---|
| count with two conditions | 162-165 | 153 | 61 |
| page with filter and sort | 365-382 | 305 | 183 |
| page, sort only | 335-340 | 146 | 84 |
count with array Contains |
350-359 | 110 | 43 |
| batch of aggregates | 370-389 | 146 | 66 |
Without the new indexes, streams were not slower than the pivot in any of the eleven queries measured; with them, 2-8x faster.
SQL Server. The gap is wider here: the pivot cost seconds and up to 1.2 GB of memory per query.
| Query | Pivot | Streams | Faster by |
|---|---|---|---|
| page by salary, wide filter | 1,090 | 66 | 17x |
| page by age and last name, narrow filter | 7,327 | 157 | 47x |
| count, filter by department | 2,917 | 88 | 33x |
| group by department | 3,217 | 116 | 28x |
| rank within department by salary, 5k rows | 2,009 | 63 | 32x |
Without a single new index, on the stock schema, the gain is 4-25x.
SQLite. It turned out that what matters most here is the connection, not the query shape. The stock SQLite page cache is 2 MB, the database file is 2.5 GB. Memory-mapping the file made any query reading more than a few thousand rows 2-3x faster, the old form included. With "value second" indexes, streams are 8-22x faster than the pivot; queries that read almost the whole scheme get nothing from the indexes.
Production data
Synthetic data is good for repeatability, but the real problems only show up on live data. Debugging ran on a copy of the production database of a transportation management application: 809k objects, 38.2 million value rows.
| Query | Before | After |
|---|---|---|
| one order number, page (PostgreSQL) | 2,464 | 30 |
| page by weight, wide filter (PostgreSQL) | 3,895 | 9 |
| a month of routes, count (SQL Server) | 639 | 63 |
| a month of routes, page by driver and trip (SQL Server) | 837 | 165 |
In the SQL Server rows a good part of the gain came not from the query form but from how the IN list is passed. The list used to go as one string through STRING_SPLIT; the server expected 50 rows from it, got 80 thousand and planned for the wrong estimate. Separate parameters made both the old and the new form 2-5x faster.
The production copy also showed what the synthetic data did not: queries with no gain and a row estimate error that had to be worked around separately. The real application's queries on this copy gave 157 correct answers out of 157.
Speed-up as a correctness check
We checked every answer of the new form not against the old form but against a count done in memory over the loaded objects. Comparing "planner on versus off" cannot see an error that both forms make. This check found 15 result errors in old and shared code: an object without a field value that vanished from Where(x => x.F == null), tree aggregates that counted over the whole scheme, a page count that ignored Take and Skip, and others. All 15 are fixed in redb Pro on the three databases; the regression tests pass with the planner on and off.
redb Pro vs EF Core: methodology
Now the comparison itself. The numbers below come from the run published on the results page, where every example is listed row by row.
- Stand: AMD Ryzen 5 5500U, 4 cores and 4 GB of memory per container; PostgreSQL 18, SQL Server Developer, SQLite.
- Versions: run of November 2, 2026, .NET 10; redb Pro built from source after the 4.5.1 release; EF Core 10.0.13 with Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3.
- Data: more than 100k objects and 8.5 million properties at the time of the run. Both databases are filled by the same generator (example E203), so the content matches and only the ids differ. Some examples create objects and were run a different number of times, so at run time the databases held about 100k and 110k employees. That does not change the order of magnitude.
- Only redb Pro is compared. The Pro packages (
redb.Core.Pro,redb.Postgres.Pro,redb.MSSql.Pro,redb.SQLite.Pro) are closed-source but free of charge with no license key for the whole 3.x and 4.x lines, commercial production use included; licensing returns only with major 5.0, and versions already in use stay free. They are enabled withAddRedbPro()at configuration. Free runs queries in the old form: it folds the values first and filters after. Streams and the leading form are Pro only. Speeding up Free is separate work and a separate write-up. - EF Core on PostgreSQL only. The EF twins were written and measured on PostgreSQL. They were not run on SQL Server or SQLite. The normal form there is the same, the same 54 tables and the same fan-out of queries, so the picture for complex graphs would be the same; we do not quote numbers for them.
- Plain LINQ only on both sides, no raw SQL. Where EF cannot express a query (window functions, tree traversal with depth, soft delete), the table shows N/A with no time.
- Both sides got indexes. For EF, the ones a developer would add for the filters: a trigram GIN for
Contains,text_pattern_opsforStartsWith, B-trees on filter and sort fields, composite "value, owner" indexes on collection tables. redb builds its indexes itself: the value indexes with each upgrade of its database module or schema, and Pro's streams indexes at start-up when they are missing. The exception is when the administrator keeps schema changes to themselves (AutoApplyDatabaseUpgrades = false). - Time to a business object on both sides. redb's time includes building
RedbObject<EmployeeProps>, EF's includes mapping the entities to the same class. - Settings: JIT off in PostgreSQL for both sides; EF gets a new context from the factory for each example, change tracking on; model building and the first connection happen before the timer.
- Three runs: the first; a repeat on warm data, where redb runs with the props cache on: the cache is empty at the start of the run and fills as it goes, so a second read of an object within one example may already come from the cache; and a third that reads through the filled cache. EF has no props cache: its repeat is compared with the third run. The per-example tables below come from the first run, where the cache plays no part.
A word about noise. The machine is shared and other work runs on it in parallel. Individual numbers drift from run to run, sometimes by 1.5-2x, so we do not count a difference below 1.5x as either a win or a loss. The order of magnitude is stable.
The numbers
Median over the examples where both sides produced a result, PostgreSQL:
| Run | Examples | redb Pro | EF Core | EF slower by |
|---|---|---|---|---|
| first | 110 | 107 ms | 816 ms | 7.7x |
| repeat | 102 | 68 ms | 790 ms | 11.6x |
| with props cache | 102 | 61 ms | 790 ms | 13.0x |
Every example row by row, for all three databases and all three runs, is on redbase.app/results.
Broken down by example (first run, 109 comparable pairs):
| Result | Examples |
|---|---|
| redb Pro faster by 5x or more | 54 |
| of those, by 10x or more | 24 |
| redb Pro faster by 1.25-5x | 26 |
| about even | 13 |
| EF Core faster | 16 |
The big gaps are queries that return whole employees. First run, ms:
| Example | Query | EF Core | redb Pro | Queries EF / redb |
|---|---|---|---|---|
| E002 | load an employee by id | 1,951 | 131 | 62 / 9 |
| E010 | Where by salary, 100 objects |
927 | 187 | 31 / 5 |
| E041 | filter on a field three levels deep | 765 | 62 | 31 / 7 |
| E051 | array Contains, several values with OR |
1,932 | 76 | 31 / 4 |
| E060 | dictionary ContainsKey |
864 | 76 | 31 / 5 |
| E080 | sort by salary | 2,835 | 136 | 58 / 8 |
| E183 | FirstOrDefaultAsync |
2,919 | 17 | 32 / 5 |
| E187 | update an existing object | 843 | 51 | 54 / 17 |
Times and query counts are from the first run; the props cache plays no part in it. redb's query count includes scheme metadata lookups. E002 loads in two steps on both sides, first an employee and then the same one by id, hence EF's twice 31 queries.
The "queries" column shows why EF loses here. Every query that returns employees turns into 31 SQL queries over the graph's tables in EF, plus assembling the entities and mapping them to the business class. The filter itself is fast in EF, the indexes are in place. Everything after the filter is what costs.
Writes favour redb too, though less: bulk insert of complex objects (E000, first run) 873 ms against 1,515, and filling the database with 100k employees (E203) 335 objects per second against 237.
Where EF Core is faster
No caveats here: on flat data and scalar operations the flat table wins. First run, ms:
| Example | Query | EF Core | redb Pro |
|---|---|---|---|
| E164 | base-field operations on one object | 19 | 50 |
| E089 | a 101-node tree, one save per node (deliberate antipattern) | 702 | 1,480 |
| E150 | sum and average over the array elements of all employees | 104 | 491 |
| E150A | aggregate over the array element at a given index | 26 | 165 |
| E193 | average with a filter | 77 | 161 |
| E194 | min and max with a filter | 160 | 268 |
| E128 | simple grouping | 294 | 454 |
| E142 | grouping by elements of a nested array | 580 | 842 |
E089 is slow on purpose, on both sides: every node is saved by a separate call, CreateChildAsync in redb and SaveChangesAsync in EF. It shows what a single save costs and serves as the baseline for the right way, a bulk insert (E088). There redb is faster: 77 ms against 218.
Eight more examples go to EF: AnyAsync with a condition, a tree count and a simple tree query, a sum over a top-level field, an average over a base field, a sum with a filter, an object without props and a mixed batch. Thirteen more are about even: CountAsync, AnyAsync, AllAsync, a projection, Min and Max, a sum over a base field, grouping with a filter, aggregates with a filter, a condition on a field and an array at once, moving a node and tree selections.
The mechanism is the same everywhere. A flat table keeps an employee's fields in one row and an array in one child table. Creating a tree node is one insert into one table; computing an average is one pass over a narrow column. In redb an object is an object row plus a row per field, an array is a row per element, and an aggregate first selects the objects of the scheme, then reads the values of another field and joins them by object. When the graph is not needed, that work gives nothing back.
It is telling how the picture changes once a nested field enters the aggregate. An average over a base field is faster in EF (E145: 30 ms against 50). A batch of aggregates that includes HomeAddress.Building.Floor (E127) needs joins to the address and building tables in EF, and the result flips: 136 ms in redb against 426 in EF.
The props cache: when you need it
redb has an object cache in process memory. It holds a whole RedbObject with its Props, keyed by object id, and serves it only if the row hash in the database has not changed. An object from a list, whose hash arrives with the row, is served with no extra query; LoadAsync by id makes one short hash check and takes the graph from memory. When the object changes in the database, its hash changes, and the next read loads it again.
That makes it clear where the cache pays off: many reads of the same objects, which also change. Reference data, cards, active orders, anything the application rereads all the time. For such a load you do not need to stand up Redis and keep it consistent with the database yourself: the hash check is built in. Each process has its own cache, not shared between nodes; the same hash check keeps a cluster correct.
Where the cache does not pay off is visible in the published run as well. On PostgreSQL the third run, reading through the filled cache, is faster than the repeat by more than 1.25x in 22 examples out of 152, within noise in 128, and slower in two. Queries that pick new objects every time get nothing from the cache, and on very short queries the hash check can add milliseconds.
Settings in RedbServiceConfiguration:
| Setting | Default | Meaning |
|---|---|---|
EnablePropsCache |
false |
turn the cache on |
PropsCacheMaxSize |
10,000 | entry limit; one entry is one object, and an object with nested references to other objects takes several entries |
PropsCacheTtl |
60 minutes | entry lifetime from the moment it was put in the cache |
Eviction is least recently used: when the entry count goes over the limit, the least recently read tenth goes at once. If eviction is frequent, the cache idles: it spends memory and hardly ever hits.
How to size it. Count not the objects that change but the ones read within one entry lifetime, together with the objects they reference. For example, if about 10k objects change per day and reads reach noticeably wider, 100k entries is a sensible starting point, and PropsCacheTtl should then go from one hour to a working day. From there, observation decides: GetStats() on the cache reports entries, hits, misses and hit rate, and the log shows a "Props cache is full" warning when more than a fifth of the limit is evicted within 10 seconds. Frequent warnings mean one of two things: raise the limit or turn the cache off.
Frequent changes: database maintenance
Changing an object in redb rewrites value rows: by default by delete and insert, while the ChangeTracking strategy in Pro writes only the values that changed. The value columns are indexed, so PostgreSQL can almost never update a row in place (HOT): an audit of a production database counted 790k updates of the value table in 9.5 hours, three of them HOT. The result is dead rows and index bloat. This is not a redb trait but ordinary PostgreSQL hygiene for any table with frequent changes to indexed columns.
PostgreSQL. The stock autovacuum threshold, 20% of the table's rows (autovacuum_vacuum_scale_factor = 0.2), means millions of dead rows before the first vacuum on a 38-million-row value table. It is worth lowering that threshold for the value table specifically. Bloated indexes are found with pgstatindex and rebuilt with REINDEX INDEX CONCURRENTLY, without blocking writes. In one of our production projects this is a nightly job on the application side: VACUUM (ANALYZE) of the value and object tables, then a rebuild of every B-tree index with more than 30% empty leaf space, smallest first, under an advisory lock so that two nodes never run the job at once. An interrupted REINDEX CONCURRENTLY leaves an invalid *_ccnew index, which the job logs for a manual drop. How much it matters: after this hygiene the production database shrank from 79 GB to 15 GB.
SQL Server. The same idea: a regular job that runs ALTER INDEX ... REORGANIZE or REBUILD depending on fragmentation, plus statistics updates.
redb deliberately ships no vacuum or rebuild schedule: these decisions depend on data volume and belong to the database administrator. The provider sources on GitHub have an sql/maintenance folder with scripts for one-off index operations.
This hygiene applies to any database with frequent changes, PostgreSQL, SQL Server or another, with redb or without it. A detailed treatment is beyond the scope of this article; the topic is covered at length in database documentation and in administrators' write-ups.
Development speed
The numbers above are about query time. For a business, developer time matters at least as much.
In redb the model is a C# class. At start-up the database scheme is reconciled with the class: a new field, a removed field, a type change are applied automatically, with no migration files. Removing a field from the class removes it from the scheme together with its values; for rollouts where the old and new versions share the database, this is switched off (DefaultStrictDeleteExtra = false). When the meaning of the data changes and not just its shape (a field split in two, values recalculated), Pro has data migrations, IRedbMigration<T>. But most model edits need none of that.
This is felt most while the business is still looking for the product's shape. The model changes daily: a field added to an order, an address moved into its own class, a string turned into a dictionary. In EF each such edit means a migration, applying and rolling it back, and edits to entities, Include chains and mapping. In our example that is 54 entities, a 930-line context, 31 Include calls and about 360 lines of mapping, all of which has to be kept consistent. In redb you edit one class and work with it as a business object right away.
Projections are there too: Select takes fields at any depth, dictionary entries included, and the server reads only those (shortened from example E075):
var rows = await redb.Query<EmployeeProps>()
.Where(x => x.Salary > 60000m && x.HomeAddress!.Building!.Floor > 10)
.OrderByDescending(x => x.Salary)
.Take(100)
.Select(x => new
{
x.Props.FirstName,
x.Props.Salary,
HomeCity = x.Props.HomeAddress!.City,
HomeFloor = x.Props.HomeAddress!.Building!.Floor,
HqCity = x.Props.OfficeLocations!["HQ"].City
})
.ToListAsync();
To be fair: EF does not lose on projections, it is one select over a few columns, and in the run E075 is even (271 ms for EF, 292 for redb). redb's gain here is not speed but that the projection is written against the same business class, without knowing which of the 54 tables holds the field.
What the team gets in the end:
- The model is one class. No
DbContext, no Fluent API, no entity per collection. - A schema without migration files. New and removed fields and type changes are applied at start-up.
- A business object straight away.
LoadAsyncandQuery<T>().ToListAsync()returnRedbObject<T>with the whole graph, with noIncludeand no mapping. - LINQ at any depth. Filters, sorts and projections over nested classes, arrays and dictionaries are plain C# expressions.
- Trees out of the box. Object hierarchies and queries over them (
TreeQuery,WhereHasAncestor,WhereHasDescendant), with depth as a parameter. - What plain EF Core LINQ does not have. 45 of the 155 EF twins stayed N/A: tree queries of any depth (17 examples), window functions
RANK,ROW_NUMBER,LAG/LEAD,NTILE, running sums (11 examples), soft delete into a trash that releases unique keys, unique keys over the content of a nested class, a subtree or list elements. - No workarounds. The EF twins needed
OrderBy(_id)in 54 examples only so thatTakewith split queries would not lose graph children;DISTINCT ONhad to be emulated by grouping and a second load. redb needs neither. - Analytics in LINQ.
GroupBywith keys from nested fields and list items, grouping by elements of arrays at any depth (GroupByArray),WithWindowwindows withWin.RowNumber,Rank,Lag,Lead,Ntile, a batch of aggregates in one pass (AggregateAsync); all of it works over a tree as well. - Indexes out of the box. For EF the filter indexes were picked by hand; redb ships with its own and updates them itself when its database module version changes, and Pro adds the streams indexes at start-up.
- SQL visible before it runs.
ToSqlStringAsync()shows the text of a query, grouping or window, and in Pro the planner's decision as comments too. - Observability. Every database command is its own OpenTelemetry span from
RedbActivitySource. - Several nodes at start-up. The schema is created and upgraded under a lock, once, even when nodes start together.
- One model, three databases. PostgreSQL, SQL Server and SQLite with the same application code;
redb exportandredb importmove a whole database between them. - A relational copy on demand.
redb flatunfolds the data into normal form for reports and analytics.
What to choose
The numbers lead to a simple rule.
redb where the object is a graph. An order with line items, addresses and status history. A customer card with contacts, documents and a dictionary of attributes. A route with stops, cargo and trips. In normal form such objects scatter over dozens of tables, and every read reassembles them. redb keeps the graph in two tables, reads it in bulk and changes the schema together with the class, without migrations.
EF Core and a flat table where the data is flat. Logs, events, sensor readings, time series: many rows with a few scalar fields, bulk writes and aggregates over columns. You can store that in redb, but it is an antipattern: every log row becomes an object row plus a row per field, writes and storage multiply, and there is no graph to gain from.
Together. redb needs no separate database and does not compete with EF for the model. Graph objects live in redb, logs and trends in ordinary tables next to them, in the same database. And if a graph has to go to reports or analytics in relational shape, redb flat unfolds it into normal form, data included.
Summary
The comparison on 100k employees showed two things, and both are honest. On complex graphs redb Pro is an order of magnitude faster than EF Core: the median by 7.7-13x, individual queries by tens and hundreds of times. On flat data and scalar aggregates EF Core is faster, sometimes by several times.
redb Pro's speed on graphs rests on three things: the graph is stored in two tables and read in bulk, field queries run as streams instead of folding everything, and "structure, value, object" indexes let a narrow condition or a page start in the right place. All the numbers of the run are published on redbase.app/results. The examples, the EF twins and the flat schema are in the open repository, and the run can be repeated on your own hardware. redb Pro costs nothing for that: on the 3.x and 4.x lines the packages are free and need no key, commercial production use included.
If this was useful, a ⭐ on GitHub helps others find it.
More of my writing: redbase.app/articles, and on dev.to.