- Published on
CAML Query vs PnPjs Filter — Which to Use and When
Every time you need to query a SharePoint list in SPFx you face the same choice: CAML query or PnPjs OData filter.
Most developers default to OData filters because the syntax is cleaner and PnPjs makes them easy to compose. But CAML is not just a legacy format — there are specific scenarios where it is the only option, performs significantly better, or is simply the right tool.
This guide gives you a clear decision framework with real code examples for both.
🗺️ What Are They?
OData filters are applied via the $filter query string parameter in the SharePoint REST API. PnPjs exposes them through the .filter() chain method. They are evaluated server-side, but SharePoint translates them into CAML internally before executing. This translation has limits — not every OData expression maps to a valid CAML query.
CAML (Collaborative Application Markup Language) is SharePoint's native query language. When you need server-side sorting, grouping, cross-list joins, or recursive folder queries, CAML is the only option. PnPjs supports it via .getItemsByCAMLQuery().
✅ When to Use PnPjs OData Filter
Use OData filter when:
- Your query is a simple equality, comparison, or string operation on indexed columns
- You are composing filter conditions dynamically at runtime
- You want readable, maintainable TypeScript code
- The list is under 5,000 items or your filter columns are indexed
Basic equality and comparison:
// Items where Status equals 'Active'
const activeItems = await sp.web.lists
.getByTitle('Projects')
.items
.filter(`Status eq 'Active'`)
.select('Id', 'Title', 'Status', 'DueDate')
.orderBy('DueDate', true)
.top(50)();
Multiple conditions:
// Items due in the next 7 days AND assigned to the current user
const myUpcoming = await sp.web.lists
.getByTitle('Tasks')
.items
.filter(
`AssignedToId eq ${currentUserId} and DueDate le '${sevenDaysFromNow}' and Status ne 'Completed'`
)
.select('Id', 'Title', 'DueDate', 'Status')
.orderBy('DueDate', true)();
Dynamic filter composition:
function buildFilter(filters: Record<string, string>): string {
return Object.entries(filters)
.map(([field, value]) => `${field} eq '${value}'`)
.join(' and ');
}
const filter = buildFilter({ Department: 'Engineering', Status: 'Active' });
const items = await sp.web.lists.getByTitle('Staff').items.filter(filter)();
OData filter syntax is easy to read, easy to compose dynamically, and straightforward to unit test. Prefer it unless you have a specific reason not to.
✅ When to Use CAML Query
Use CAML when:
- Your filter column is not indexed and the list has more than 5,000 items (CAML is required for threshold-safe queries via
<ViewScope>and<RowLimit>with paging) - You need
<GroupBy>— grouping is not available in OData - You need
<Joins>— cross-list lookups are CAML-only - You need
<ProjectedFields>— to project fields from a joined list - You need recursive folder queries (
ViewScope="RecursiveAll") - You are querying a document library and need to traverse folder hierarchies
Basic CAML query:
import { CamlQuery } from '@pnp/sp/lists';
const query: CamlQuery = {
ViewXml: `
<View>
<Query>
<Where>
<Eq>
<FieldRef Name="Status" />
<Value Type="Choice">Active</Value>
</Eq>
</Where>
<OrderBy>
<FieldRef Name="DueDate" Ascending="TRUE" />
</OrderBy>
</Query>
<RowLimit>50</RowLimit>
</View>
`
};
const items = await sp.web.lists
.getByTitle('Projects')
.getItemsByCAMLQuery(query);
Recursive folder query (all files in all subfolders):
const query: CamlQuery = {
ViewXml: `
<View Scope="RecursiveAll">
<Query>
<Where>
<Eq>
<FieldRef Name="FSObjType" />
<Value Type="Integer">0</Value>
</Eq>
</Where>
</Query>
<RowLimit>500</RowLimit>
</View>
`
};
const allFiles = await sp.web.lists
.getByTitle('Documents')
.getItemsByCAMLQuery(query);
Cross-list join via CAML:
// Join Tasks list to Projects list via ProjectId lookup field
const query: CamlQuery = {
ViewXml: `
<View>
<Query>
<Where>
<Eq>
<FieldRef Name="Status" />
<Value Type="Choice">In Progress</Value>
</Eq>
</Where>
</Query>
<Joins>
<Join Type="LEFT" ListAlias="Projects">
<Eq>
<FieldRef Name="ProjectId" RefType="ID" />
<FieldRef List="Projects" Name="ID" />
</Eq>
</Join>
</Joins>
<ProjectedFields>
<Field Name="ProjectTitle" Type="Lookup" List="Projects" ShowField="Title" />
</ProjectedFields>
<RowLimit>100</RowLimit>
</View>
`
};
const tasksWithProjects = await sp.web.lists
.getByTitle('Tasks')
.getItemsByCAMLQuery(query);
// Access the projected field
tasksWithProjects.forEach(item => {
console.log(item.ProjectTitle); // From the joined Projects list
});
📊 Decision Table
| Scenario | Use |
|---|---|
| Simple equality/comparison filter | OData |
| Multiple AND/OR conditions | OData |
| Dynamic runtime filter composition | OData |
| Indexed columns, list under 5,000 items | OData |
| List over 5,000 items, unindexed columns | CAML |
| GroupBy / aggregate views | CAML |
| Cross-list join via lookup column | CAML |
| Recursive folder/document library traversal | CAML |
ViewScope control (files vs folders vs all) | CAML |
| Complex date range with threshold safety | CAML |
⚠️ The OData Filter Threshold Trap
This is the most common mistake: using an OData filter on an unindexed column in a list with more than 5,000 items. SharePoint returns:
The attempted operation is prohibited because it exceeds the list view threshold.
The fix is one of:
- Index the filter column in list settings (fast, no code change)
- Rewrite the query as a CAML query with
<RowLimit>and use paging (covers cases where indexing is not possible)
If you cannot index the column and cannot paginate, your only option is to use a search-based approach instead of a direct list query.
🔀 Combining Both — CAML for Structure, OData for Convenience
You can use CAML for the structural aspects (grouping, scope, joins) and still use PnPjs chaining for $select and $expand on top:
const items = await sp.web.lists
.getByTitle('Documents')
.getItemsByCAMLQuery(
{ ViewXml: `<View Scope="RecursiveAll"><RowLimit>200</RowLimit></View>` },
'File/Name', 'File/ServerRelativeUrl', 'FileSizeDisplay' // expand fields
);
The second argument to getItemsByCAMLQuery is a list of fields to expand — useful for file metadata on document libraries.
✅ Summary
- OData filter via PnPjs
.filter()is the default — use it for simple, readable, dynamically composable queries on indexed columns. - CAML query via
.getItemsByCAMLQuery()is required for grouping, cross-list joins, recursive folder queries, and large unindexed lists over the 5,000-item threshold. - The most dangerous OData mistake is filtering on an unindexed column in a large list — SharePoint throttles the query at 5,000 items. Index the column or switch to CAML with paging.
- CAML is not "old" or "deprecated" — it is the only way to express certain query patterns in SharePoint. Know when to reach for it.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
