Include
include attaches related records to each row: customers with their orders, orders
with their order items and product. On collection-valued relationships the included
branch can additionally carry its own query operations — a slice instead of the whole
collection ("every customer, but only their three most recent delivered orders"). That is
the pattern behind dashboard cards and order-history previews: bounded related data
without dragging thousands of child rows across the wire.
The loading happens server-side (SQL joins / follow-up queries — never a change-tracking fixup), and the set of navigations a client may name is entirely up to your governance configuration.
Core rule
includespecifies a relationship to include. When the relationship is collection-valued,filter,sort, andtakecan be used inside its block to control the included collection.
Single-valued (reference) relationships can be included — with or without nested
includes — but the collection operations filter, sort, and take cannot be applied
to them. Cardinality comes from the relationship metadata (your entity/DTO model),
never from plural names, record counts, or runtime data.
Simple inclusion
One or more comma-separated navigation paths, using dots for depth. Works for both collection and single-valued relationships:
GET /api/customers?include=Orders
GET /api/customers?include=Orders,Orders.OrderItems
GET /api/orders?include=Customer.Address- Duplicates collapse —
include=Orders,OrdersloadsOrdersonce. - Every path must resolve to navigation properties end to end. A path that walks
into a scalar property (or names something that does not exist) is rejected with
INCLUDE_PATH_NOT_FOUND/NAVIGATION_PROPERTY_REQUIRED. - The wire parameter is
includein the native DSL and FQL; MiniOData clients spell the same thing as$expand=Orders(see Query Syntax).
Collection include: filter, sort, take
An include entry may carry a parenthesized options block — but only when the target relationship is a collection:
GET /api/customers?include=Orders(take=5)
GET /api/customers?include=Orders(filter=Status:eq:Delivered)
GET /api/customers?include=Orders(sort=OrderDate:desc)
GET /api/customers?include=Orders(take=5;filter=Status:eq:Delivered;sort=OrderDate:desc)include-entry: path [ "(" option *( ";" option ) ")" ]
option: "filter=" <filter expression in the request's syntax>
| "sort=" <sort expression>
| "take=" <integer ≥ 0>
| "include=" <path, optionally with its own block> (nested)
| <path> "(" ... ")" (compact nested block)take=5loads at most five children per parent row —take=0loads none.filter=uses the same expression language as the rootfilter: DSL requests writefilter=Status:eq:Delivered, FQL requestsfilter=Status = 'Delivered'— the syntax of the request governs; they are never mixed.sort=acceptsOrderDate:desc(DSL) orOrderDate desc/OrderDate DESC(FQL).- Options inside a block are separated by
;(URL-safe choice; prefer it since,separates include entries). - Each path may appear once per query —
Orders(take=1),Orders(take=2)is rejected withINCLUDE_DUPLICATE_PATH; merge the options into one block.
What relationship-level blocks do not support
skipis not a relationship-level option. To bound an included collection usetake; paging (page/pageSize, and MiniOData$skip) remains a root-only concern. Requests such asinclude=Orders(skip=10)fail at parse time with a clear message.- Only
filter,sort,take, and nestedincludeare valid option keys — anything else (including the removedexpandkeyword) fails parse.
Single-valued relationships
GET /api/customers?include=Address ✓ loads the customer's address
GET /api/customers?include=Address() ✓ same — an empty block is a no-op
GET /api/customers?include=Orders(include=Customer) ✓ nested reference inclusionbut each collection operation on a reference is rejected individually:
GET /api/customers?include=Address(take=5)
→ Include query validation failed:
The 'take' option can only be used with collection relationships.
'address' is a single-valued relationship. (INCLUDE_TAKE_ON_REFERENCE)
GET /api/customers?include=Address(filter=City:eq:Manila)
→ The 'filter' option can only be used with collection relationships.
'address' is a single-valued relationship. (INCLUDE_FILTER_ON_REFERENCE)
GET /api/customers?include=Address(sort=City:asc)
→ The 'sort' option can only be used with collection relationships.
'address' is a single-valued relationship. (INCLUDE_SORT_ON_REFERENCE)The decision is metadata-driven: a collection relationship stays a collection even when it has zero or one rows today; a reference stays single-valued even when it is null.
Nested includes
Deeper levels ride inside the block — each level is validated independently against the relationship it names:
GET /api/customers?include=Orders(take=5;filter=Status:eq:Delivered;include=OrderItems(take=3))Equivalently via the compact child-block or dotted forms:
GET /api/customers?include=Orders(take=5;OrderItems(take=3))
GET /api/customers?include=Orders(take=5),Orders.OrderItems(take=3)A nested include on a single-valued relationship is fine for traversal
(Orders(include=Customer) works), but its collection options are not
(Order.Customer(take=5) is rejected — customer is single-valued).
A deep tree produces one batched level per depth that carries options. Branch
filter/sort/take never trim parent rows: root paging and totalCount describe the full
result set.
Worked example: bounded order history
GET /api/customers?include=Orders(filter=Status:eq:Delivered;sort=OrderDate:desc;take=3){
"data": [
{
"id": 4,
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"city": "London",
"status": "Active",
"salary": 90000,
"createdDate": "2023-05-17T00:00:00Z",
"orders": [
{ "id": 101, "orderNumber": "ORD-101", "totalAmount": 129.90, "orderDate": "2024-05-01T00:00:00Z", "status": "Delivered" },
{ "id": 118, "orderNumber": "ORD-118", "totalAmount": 89.00, "orderDate": "2024-04-12T00:00:00Z", "status": "Delivered" }
]
}
],
"totalCount": 42
}Customers without qualifying orders still appear (they just carry "orders": []).
Branch operations never change the root sort order — the root query and each branch are
separate SQL operations.
Response shape
- Only requested branches are sent. Children of
Orders(likeOrderItems) stay out of the response until you ask for them — including a navigation loads its scalar fields, not its own children. - Parents are never dropped. A customer with zero orders gets an empty array.
Combining with select
include decides what gets loaded; select decides what gets returned:
GET /api/customers?include=Orders&select=Id,Orders(Id,TotalAmount)Selecting through a navigation path requires that path in include (otherwise
QueryValidationException, NAVIGATION_PROJECTION_REQUIRES_INCLUDE — the message tells
you which include= value to add). A deep include such as
include=Orders.OrderItems(take=5) also covers its parent chain (Orders), since the
provider must load the parent to reach the child.
Governance: which paths are includable
opt.AllowedIncludes = ["Orders", "Orders.OrderItems"];- With
AllowedIncludesset, every explicitly requested include path (including nested branches and every level of a block) must be on the list, or the request is rejected withINCLUDE_ACCESS_DENIED(in lenient mode the unauthorized branch is dropped from the response — a security property, not a convenience). Implicit ancestors that exist only to carry a deeper requested path are not themselves governed. - With it unset, any valid navigation of the entity type may be included. For public
endpoints, set it explicitly — unbounded
includetrees are the classic data-exfiltration and cartesian-blow-up vector. - Branch
filter/sortfields must be valid for the related entity's public surface — DTO-typed surface checks apply exactly like at the root, andtype.membergovernance rules likeOrder.Total:gt:100remain available to the server.
Provider behavior
- EF Core: blocks are applied inside EF's own filtered-include machinery — the
database trims the children before rows reach memory, so a
take=3branch loads three rows per parent, not the whole collection. Grouping queries rejectincludeoutright (GROUPBY_INCLUDE_CONFLICT). - Dapper: each option-bearing level runs as its own batched query restricted to the
keys on the current parent page.
takebecomes a dialect-correct ranked subquery (ROW_NUMBER() OVER (PARTITION BY ...)) so trimming is also server-side, while afilter-only branch is folded straight into the childWHERE. A single bare include even collapses to one streamed join command behind the scenes.
Common mistakes
What changed from v3/v4-preview
The old v3 FilteredIncludes pseudo-syntax — and the short-lived v4-preview companion
keyword expand — are replaced. Everything now lives on include:
| v4 preview | v4.0 |
|---|---|
include=Orders | include=Orders (unchanged) |
include=Orders&expand=Orders(take=3;filter=…) | include=Orders(take=3;filter=…) |
expand=Orders alone | rejected — expand no longer exists in DSL/FQL; use include=Orders |
Related
- Projection — shape what loads into what returns
- Validation — the full error code table
- Security & Governance