logo
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

ScenarioUse
Simple equality/comparison filterOData
Multiple AND/OR conditionsOData
Dynamic runtime filter compositionOData
Indexed columns, list under 5,000 itemsOData
List over 5,000 items, unindexed columnsCAML
GroupBy / aggregate viewsCAML
Cross-list join via lookup columnCAML
Recursive folder/document library traversalCAML
ViewScope control (files vs folders vs all)CAML
Complex date range with threshold safetyCAML

⚠️ 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:

  1. Index the filter column in list settings (fast, no code change)
  2. 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!

Ad image