I’ve been telling people to use CAML joins since 2025. I wrote a whole post about it. I put it in my pick-one playbook as a reason CSOM still owns list items.

And I had never measured it.

Not properly. Never all the reasonable ways of fetching the same data, against the same lists, on the same day. It’s always been case by case: this feels like the better solution here, that one felt slow last time. In my head, one call instead of four has to be faster, and that was enough.

That’s not a benchmark. That’s a hunch I’d been repeating with confidence.

So I built the comparison I should have built a year ago. It moved my numbers, and it also corrected something I’d been saying wrong for a year.

TL;DR

I finally measured the CAML join advice I’ve been giving since 2025. Pulling 1,000 rows across four lists chained by lookup columns, the join takes 1 request and ~254 ms through CSOM. Everything else is slower, even after I tuned each approach to the ceiling I measured for it: OData expands need 4 requests and 548 ms, and Microsoft Graph 4 and 1,655 ms, because it can’t traverse a lookup at all. Two things I got wrong for a year: the join isn’t a CSOM feature, and on REST a plain $batch of four queries actually beats it. One trap: over POST .../GetItems the join runs and filters correctly, but the projected columns come back missing with a 200 OK.

Is a CAML join actually faster than four separate queries?

Yes, and by more than I expected. Fetching 1,000 rows across a three-hop lookup chain, the CAML join needed 1 HTTP request and about 254 ms. The same rows through four separate CSOM queries took 6 requests and 1,192 ms, OData expands over SharePoint REST took 4 requests and 548 ms, and Microsoft Graph took 4 requests and 1,655 ms. The join wasn’t just fewer calls, it was 4.7x faster than the four-query CSOM version and 6.5x faster than Graph. The closest anything got was SharePoint REST with $batch, at 355 ms, and that one deserves its own paragraph rather than a footnote.

Same Rows, Eight Ways

Four lists, each pointing at the next through a lookup column:

Main -> OrderTasks -> Orders -> Customers

Every approach has to return the same thing for each of the 1,000 items in the main list: its id, its title, and the customerNo and customerName from a customer sitting three hops away.

The join version, built with CAMLEX:

const string OrderTasks = "ordertasks", Orders = "orders", Customers = "customers";

CamlexNET.Interfaces.IQuery query = Camlex.Query()
    .LeftJoin(x => x["OrderTaskLookUp"].ForeignList(OrderTasks))
    .LeftJoin(x => x["OrderDetailLookUp"].PrimaryList(OrderTasks).ForeignList(Orders))
    .LeftJoin(x => x["CustomerLookUp"].PrimaryList(Orders).ForeignList(Customers))
    .ProjectedField(x => x["customerNo"].List(Customers).ShowField("customerNo"))
    .ProjectedField(x => x["customerName"].List(Customers).ShowField("customerName"))
    .ViewFields(["ID", "Title", "customerNo", "customerName"]);

var items = mainList.GetItems(query.ToCamlQuery());
context.Load(items);
context.ExecuteQuery();

What’s happening here?

  1. Each LeftJoin walks one lookup hop. PrimaryList is the list you’re coming from, ForeignList the one you’re going to, which is what lets hops two and three start somewhere other than the main list. Those arguments are aliases you invent, not list ids: they’re string, and the compiler will tell you so (cannot convert from 'System.Guid' to 'string') if you try otherwise. SharePoint resolves the actual list from the lookup column’s own configuration, so "orders" works exactly as well as a GUID. I checked, because I’d been passing GUIDs for a year without knowing they were only ever labels.
  2. ProjectedField pulls a column out of a joined list and gives it a name you can use as if it lived on the main list.
  3. ViewFields is not optional, and this is the part I’d been getting away with rather than getting right. A projected field that isn’t listed there comes back absent, with no error. Same query, same joins, one <FieldRef> removed: the column is simply not on the item.
  4. ToCamlQuery(), not ToString(). Bare ToString() gives you <Joins> and <ProjectedFields> as two sibling roots with no <View> around them, which isn’t a well-formed document at all. SharePoint accepts it anyway and hands back all 1,000 rows, minus the projected columns, because there’s no <ViewFields> in a fragment. A silent wrong answer is worse than an exception, and I’d shipped that line.
  5. The values come back as FieldLookupValue, not string, even when the source column is plain text. The text is in .LookupValue, which trips people up the first time.

Every approach below returned an identical result set once normalised, checked by hashing the rows and comparing against a hash computed from the seed data. The wire shapes differ wildly, as you’ll see. The data doesn’t, and that part matters more than the timings: a fast query that quietly returns 998 rows instead of 1,000 isn’t fast, it’s broken.

One Fat Request Beats Four Lean Ones

1,000 items in the main list, 2 warmup runs discarded, 10 timed runs per approach, interleaved so no approach got a systematically better slot. Every arm asks for the minimum it needs and no more, and every arm is chunked or paged at the ceiling I measured for that specific API rather than at a number I liked the look of. That last part cost me a rerun, and I’ll come back to it.

ApproachHTTP requestsQueriesMedian
CAML join, CSOM11254 ms
SharePoint REST $batch14355 ms
CAML join, SharePoint REST *11427 ms
CSOM batched, no join14518 ms
SharePoint REST, OData expands44548 ms
Microsoft Graph $batch14613 ms
CSOM, four separate queries661,192 ms
Microsoft Graph441,655 ms

* RenderListDataAsStream, not GetItems. REST exposes CAML two ways and only one of them returns the projected columns, which is the next section and the single most useful thing I learned building this. The row also carries that endpoint’s view chrome, a couple of dozen fields per row that nothing can trim, so it is paying for bytes the CSOM join never sends.

Graph needs a request per list because it can’t traverse a lookup. It isn’t quite empty-handed: select the lookup’s internal name and you get its display value alongside the id, so OrderDetailLookUp comes back as "Order 00000" next to OrderDetailLookUpLookupId: "1". That’s one column from the related item, for free, and it’s the one the lookup was configured to show. Any other column of that item, or another hop beyond it, needs its own request. My chain wants customerNo and customerName three lists away, and a lookup has exactly one ShowField, so Graph fetches all four lists and joins them in C#.

Four requests, one per list. That number used to be seven, and the three extra ones were mine, not Graph’s: I had paged at $top=999 out of habit, because 999 is the ceiling stuck in my head from /users and directory objects. It isn’t a SharePoint number. Graph’s default here is 200 a page and it honours $top literally, which I only established by seeding a 1,200-item list and counting the first page: $top=999 gives exactly 999, $top=1001 gives exactly 1,001.

Don’t read a 200 OK as a yes, either. $top=100000 is accepted too, and just returns the list. Graph never complains about an over-large $top, so a non-error tells you nothing and the only honest test is to hold more rows than you think the ceiling is. Mine ran out at 1,200 without finding one.

The OData row is the interesting loser. It’s the leanest of the lot on the wire, because odata=nometadata is compact and I only selected the lookup id columns. It still loses on time, because four sequential round trips cost more than one fat one. Being economical doesn’t help when you’re economical four times in a row.

CAML Isn’t a CSOM Feature, and I Had That Wrong

Here’s the correction. I’ve been writing as though CAML joins were a CSOM capability, and framing the choice as CSOM versus REST. That’s wrong twice over.

CAML is SharePoint’s query language, not a CSOM feature, and SharePoint REST will run it. There are two routes, and they don’t behave the same:

POST /_api/web/lists(guid'<list-id>')/GetItems
{ "query": { "ViewXml": "<View>...</View>" } }

POST /_api/web/lists(guid'<list-id>')/RenderListDataAsStream
{ "parameters": { "ViewXml": "<View>...</View>" } }

They don’t share a body shape, which is the first small tax. GetItems takes an SP.CamlQuery under query; RenderListDataAsStream takes a parameter bag under parameters. Send odata=nometadata and drop the __metadata annotation the old docs show, or you get The property '__metadata' does not exist on type 'SP.CamlQuery'.

RenderListDataAsStream runs the join and gives you the projected columns:

{ "Row": [{
    "ID": "1",
    "Title": "Main 00000",
    "customerNo": "C-F0026",
    "customerName": "Filler Company 0026"
}]}

That’s the full three-hop join, over REST, one request. It’s the CAML join, SharePoint REST row in the table above, and it beats every approach that doesn’t join except one, which is the next section.

That snippet is trimmed, though, and the trimming matters. RenderListDataAsStream doesn’t answer in the shape OData or CSOM do. The rows live under Row instead of value, every value is a string ("ID": "1", not "Id": 1), and each row arrives with a couple of dozen fields you never asked for: PermMask, FSObjType, UniqueId, ContentTypeId, SMTotalSize, ScopeId, owshiddenversion, and FileRef in four separate encodings. <ViewFields> doesn’t trim any of it, and neither does RenderOptions: 2. I’d assumed that one was the “just the list data” flag; the docs define ListData as “Return list data (same as None)”, so it’s the default output under a more promising name. It’s a view-rendering endpoint, not an API, and you deserialize it accordingly.

GetItems is where it gets nasty. Send the same join and you get 200 OK, the right number of rows, and no customerNo or customerName anywhere in the response. The columns are simply gone.

Before you write to tell me I forgot <ViewFields>: I didn’t, and I checked that specifically, because it’s the obvious answer and Microsoft is explicit that a projected field has to be named there too. It was in the request. The same <ViewFields>, the same joins, the same projected fields come back fine over RenderListDataAsStream and fine over CSOM. Take the <FieldRef> out over CSOM and the value disappears exactly as documented, so the mechanism works and this isn’t it. GetItems drops the projection with the request fully formed.

I assumed that meant the join had been ignored. It hasn’t, and I made myself prove it rather than infer it from a row count. I put a <Where> on the projected customerNo and compared the returned item ids against the exact set my test data says should match: 10 of 1,000 at one value, 500 of 1,000 at another, and zero for a customer that doesn’t exist. All three came back as exact set matches, not just matching counts. The clincher is that customerNo isn’t a column on that list at all, so there is nothing local for the filter to resolve against; the projection is the only path.

So the join executes server-side, the filter across three lists is correct, and only the projected columns are lost on the way out. GetItems is usable for finding items by a value two lists away. You just can’t read that value back, which makes it a fine filter and a useless projection.

Graph has neither route. Be careful how you state that, though, because “Graph can’t do lookups” is too strong and I had it too strong myself until Rob Windsor’s post made me go and check. Graph reads a lookup’s value perfectly well. What it cannot do is treat that lookup as a path to the rest of the related item.

I checked properly rather than taking anyone’s word for it. Ask Graph to expand a lookup and it tells you exactly what’s wrong:

Parsing OData Select and Expand failed: Could not find a property
named 'OrderDetailLookUp' on type 'microsoft.graph.listItem'.

The metadata says the same thing more permanently. Pull https://graph.microsoft.com/v1.0/$metadata and listItem declares six navigation properties: analytics, documentSetVersions, driveItem, fields, permissions and versions. A lookup column is not among them, and could not be. Your columns live inside fields, declared as <EntityType Name="fieldValueSet" BaseType="graph.entity" OpenType="true" />: an open type, whose properties are whatever your columns happen to be called. fields itself is a navigation property, which is why $expand=fields works at all, but nothing inside it is one, because a column invented at runtime can’t be declared in a schema. There is nothing for $expand to follow. That’s a design decision, not a gap in the documentation.

Which is why you get the display value and nothing more. The lookup’s ShowField is copied into the field bag as a value, so it travels with the item. Everything else stays behind in the other list. One thing I did not test: SharePoint lets you tick extra columns when you create a lookup, under “Add a column to show each of these additional fields”. Those are secondary lookup columns on the list holding the lookup, and Graph models them as ordinary columns with lookup.primaryLookupColumnId pointing back at the primary, so they’d presumably ride along the same way. That only ever spans one hop, so it wouldn’t have rescued a three-list chain, but if your relationship is a single hop it’s worth knowing about before you write a second request.

Batching Gets You the Round Trip, Not the Query Count

This is the nuance I’d have missed if I’d only counted round trips, and it is where the join’s lead is narrowest.

All three stacks can put several queries in one HTTP request. CSOM queues GetItems calls onto one context and sends them in a single ExecuteQuery. SharePoint REST has POST /_api/$batch, multipart and awkward but real. Graph has POST /v1.0/$batch, JSON and pleasant, capped at 20 sub-requests: the 21st comes back Number of requests inside batch exceed the limit.

And batching works. Here is the row I did not want to find:

HTTP requestsQueriesResponse bytesMedian
CAML join, CSOM11988,195254 ms
SharePoint REST $batch14156,502355 ms
CAML join, SharePoint REST111,020,047427 ms

The REST one is uglier than it should be, because SharePoint’s $batch is multipart rather than JSON. Each part is a whole HTTP request with its own headers, and the blank lines are load-bearing:

POST /_api/$batch
Content-Type: multipart/mixed; boundary=batch_a1b2c3

--batch_a1b2c3
Content-Type: application/http
Content-Transfer-Encoding: binary

GET https://the-tenant.example/sites/x/_api/web/lists(guid'<id>')/items?$select=Id,Title&$top=5000 HTTP/1.1
Accept: application/json;odata=nometadata

--batch_a1b2c3--

Repeat the part per query. The response comes back as multipart/mixed too, one HTTP/1.1 200 block per sub-request in the order you sent them, which you then have to pull the JSON out of yourself. Graph’s version is a JSON array of {id, method, url} and is far nicer to write, but it may reorder its responses, so key them by id rather than by position.

On REST, batching four plain queries beats the join. Not on round trips, which tie at one, but on time and on an order of magnitude of bytes. The join is still the fastest thing overall, but “always join” is the wrong lesson: it’s “always join on CSOM”. The REST join loses because RenderListDataAsStream ships a megabyte of view chrome nobody asked for, and four lean OData queries in one envelope simply carry less.

So batching genuinely buys the round trip. What it does not buy is the query count, and that column is the one to read. The join is one query. The batched arms are three or four, riding together. Whether SharePoint charges you per request or per operation is not something my data settles, and the throttling docs say resource units are counted per operation inside a batch, so I would not assume the envelope is free.

Batching also can’t filter. It drags all four lists back whole, 3,051 rows to answer a question about 1,000 of them, because you don’t know which ids you need until the first response comes back. Which brings me to the claim I’ve been repeating for a year, and which this exercise did not so much confirm as dismantle.

I’ve been saying “6 resource units versus 2”, on the basis that a multi-item query costs 2 resource units. Go back to the throttling page and read which sentence that table sits under: “Microsoft Graph APIs have a predetermined resource unit cost per request.” Much further down, past four more tables and into the “How to handle throttling?” section, the same page says “CSOM and REST don’t have a predetermined resource unit cost, and they usually consume more resource units than Microsoft Graph APIs to achieve the same functionality.”

So the 2-units-per-query figure was never a price for the CSOM queries I was applying it to. My arithmetic borrowed Graph’s price list for a different shop. In fairness the same page does bless the number as an estimate, “you can estimate the request rate using an average of 2 resource units per request”, which is a reasonable way to size a request rate and not a per-call cost you get to multiply by four and quote as a fact. The direction of the argument survives, because fewer queries is still fewer queries. The certainty doesn’t.

And I should say where I said it, because it’s still sitting there in my own writing. The 2025 joins post I linked at the top counts the units out query by query in its code comments and closes on “66% fewer resource units”. The CSOM performance playbook runs the same arithmetic. Both of them are wrong in the same way, this paragraph is the correction, and I’d rather point at them than quietly hope you don’t click through.

I also can’t replace it with a measurement. The documented per-app limits are real and my measurements match them: 1,250 resource units per minute and 1,200,000 per 24 hours, which is the row for tenants up to 1,000 licences. What you can’t easily do is watch the meter. Microsoft’s developer blog says “when the application has consumed 80% of its resource unit quota SharePoint will start to send RateLimit headers”, and it never says which window that 80% is measured against. I found out by pushing: 1,416 requests in 14 seconds and the headers appeared, carrying RateLimit-Limit: 1250. That’s the per-minute budget, not the daily one. It’s also a state that evaporates in about ten seconds, which makes it useless as a measuring instrument.

And here’s the part that made me stop trying. The same throttling page, in a section titled RateLimit headers, currently says: “SharePoint Online does not return or support IETF RateLimit headers.” I have them in a response body from this afternoon. The commit history explains it better than the page does: that section was headed “RateLimit headers - preview” until a docs change on 7 August 2026 deleted it, with the note that there had been a preview and it was no longer there. So what I caught was a preview being switched off underneath me, eight days before I went looking. Two sections higher the page still lists “Use the Retry-After and RateLimit HTTP headers” as a best practice, which it hasn’t got round to retracting.

Honour Retry-After, treat a RateLimit header as a bonus that has already been withdrawn once, and don’t build a measuring instrument on either. It does mean nobody should be quoting per-call resource unit costs at you, including me.

Then I Added a WHERE

The part of the original post I like most is filtering on a field several lists away. So the second scenario queries the OrderTasks list and filters on the customer’s number, two hops out:

CamlexNET.Interfaces.IQuery query = Camlex.Query()
    .Where(x => (string)x["customerNo"] == customerNo)
    .LeftJoin(x => x["OrderDetailLookUp"].ForeignList(Orders))
    .LeftJoin(x => x["CustomerLookUp"].PrimaryList(Orders).ForeignList(Customers))
    .ProjectedField(x => x["customerNo"].List(Customers).ShowField("customerNo"))
    .ProjectedField(x => x["customerName"].List(Customers).ShowField("customerName"))
    .ViewFields(["ID", "Title", "customerNo", "customerName"]);

The generated Where targets the projected field directly, and the value type is Text, not Lookup:

<Where>
  <Eq>
    <FieldRef Name="customerNo" />
    <Value Type="Text">C-BROAD</Value>
  </Eq>
</Where>

Before running it I assumed the join would lose here. Filtering first and walking backwards is the obvious smart move: find the customer, find their orders, find those orders’ tasks. Three tiny, highly selective queries instead of one join across the whole list.

It doesn’t win. Filtering to 500 of 1,000 rows, the join answers in one request and 168 ms; the best reverse walk needs three requests and 365 ms, and the worst needs eight and 1,658 ms. The full table is below, at the shape you’re more likely to ship.

Reverse traversal doesn’t collapse because the idea is bad. It collapses because neither REST nor Graph has an in operator for a set of ids, so 500 ids become a chain of or clauses chopped into pieces that fit inside a query string. Thirteen requests for REST, eight for Graph, which gets to send far longer chains, and I’ll come back to why.

Batching rescues some of that, but less than you’d hope, and it’s worth being precise about why. The stages are sequential: you can’t ask for the orders until the customers have answered. So you can only batch within a stage, which is why REST reverse goes from thirteen requests to two and not to one. The thirteen queries are all still there.

At the other end, filtering down to the 10 rows that match C-NARROW, reverse traversal finally gets somewhere: REST does it in 2 requests and 187 ms, batched or not. Still slower than the same filter as a CAML join over CSOM, which answers in 1 request and 108 ms. And look at what those ten rows cost on the wire: REST reverse moves 1,607 bytes, the CAML join moves 10,229, and CSOM walking forward moves 1,825,534. Same ten rows. That is what client-side filtering means.

More Than One Customer, Which Is What You Actually Write

A single Eq is the demo. Real code usually asks for a set: give me the tasks for these three customers. That’s an <In> over the projected field, and it’s fair to ask whether the operator surface on a projected column is as complete as on a real one.

It is. I checked <In>, <BeginsWith> and <Neq> against the ids my test data says should match, over both CSOM and REST, and all six runs came back as exact set matches:

Operator on the projected customerNoExpected rowsCSOMREST
<In> with three customers520exactexact
<BeginsWith>490exactexact
<Neq>500exactexact

So here’s the whole field, at the shape you’d actually ship: 520 rows belonging to three customers, and what it costs to get them without a join. The Filter column is the one that repays reading. Only the join and the reverse walks filter on the server. Every client row fetches the entire entry list and discards what doesn’t match with a Contains in C#, because customerNo lives two hops from the list being queried and nothing but the join can express that predicate to SharePoint. A forward arm that filtered server-side would have to resolve the customer ids first, at which point it has become a reverse arm. That isn’t a straw man I built, it’s the shape of the problem.

ApproachFilterHTTP requestsQueriesMedian
CAML join, CSOMserver11169 ms
SharePoint REST $batchclient13255 ms
CAML join, SharePoint REST *server11300 ms
CSOM batched, no joinclient13351 ms
SharePoint REST, OData forwardclient33367 ms
SharePoint REST $batch, reverseserver213377 ms
CSOM per list, reverseserver44455 ms
Microsoft Graph $batchclient13509 ms
CSOM per list, forwardclient44690 ms
Microsoft Graph $batch, reverseserver39732 ms
Microsoft Graph, forwardclient331,149 ms
SharePoint REST, OData reverseserver13131,163 ms
Microsoft Graph, reverseserver991,717 ms

* RenderListDataAsStream again.

The join answers in 169 ms and one request. The cheapest thing that isn’t a join costs 1.5x that and only by giving up the filter, the honest reverse walk costs 2.2x, and the worst case is 10x and nine round trips.

Worth being precise about why the reverse arms blow up, because “no in operator” is only half of it. They do use or, and it works fine: $filter=(OrderDetailLookUpId eq 2) or (OrderDetailLookUpId eq 3) or .... What kills them is that the chain has to fit in a query string, not a URL, and each stack has its own idea of how long that may be. I bisected both rather than assuming they matched:

  • SharePoint REST refuses at 2,048 characters of query string, which is ASP.NET’s maxQueryStringLength default showing through. 46 clauses go through at 2,016 characters, 47 come back 401 at 2,059.
  • Graph goes more than twice as far, to somewhere just past 4,625 characters. 91 clauses pass, 92 come back 404 with an empty UnknownError and no explanation at all.

Each escaped clause costs about 43 characters either way, so REST fits about 44 ids per request and Graph about 86. An in operator would cost about 6 characters per id, which is why its absence hurts: same query, seven times the requests.

This is the rerun I owed you. I had originally chunked both stacks against a single 1,900 character budget measured on the whole URL, which is wrong twice: the cap is on the query string, and Graph’s path carries a 90 character site id that was eating into a budget SharePoint never had to pay. Fixing it took Graph’s reverse walk from nineteen requests to nine, which is the sort of correction that makes your own argument weaker and your numbers worth reading.

Three customers become 520 order tasks. REST needs twelve chunked calls to read them, on top of the one that found the orders. Thirteen round trips to answer one question. The more you ask for, the worse not joining gets.

What REST and Graph Actually Refuse

I checked the walls rather than assuming them, and the errors are worth having:

  • Two-level $expand in REST: 400. The docs never spell out a depth limit, but they do warn you off the shape, in one sentence you could read past: “Bulk expansion and selection of related items isn’t supported.” In practice you name one field of one lookup and that’s your lot.
  • Reaching a lookup id through an expand ($select=OrderTaskLookUp/OrderDetailLookUpId): also 400. If this worked, OData would do the whole chain in two calls.
  • $filter=Id in (1,2,3) in REST: 400. No in operator, hence the or chains.
  • Graph filtering on a column that lives on the lookup target: 400. You can only filter fields/CustomerLookUpLookupId, the integer.
  • Graph in (...) on a lookup id: 400. But or chaining works, which is the only reason Graph’s reverse arm is nine requests and not 500.
  • Graph expanding a lookup at all: 400, “Could not find a property named ‘OrderDetailLookUp’ on type ‘microsoft.graph.listItem’”. Try it inside fields instead and the type name changes but the answer doesn’t: $expand=fields($expand=OrderDetailLookUp) is 400, “Could not find a property named ‘OrderDetailLookUp’ on type ‘microsoft.graph.fieldValueSet’”.
  • Asking for a path through a lookup, $expand=fields($select=OrderDetailLookUp/Title): 200, which looks like a win until you read it. fields contains exactly one thing, "OrderDetailLookUp": "Order 00000", the lookup’s own display value. The /Title was quietly ignored rather than honoured or rejected.

REST does have one trick I didn’t expect: you can filter on an expanded lookup’s column, even one that isn’t the lookup’s ShowField. $filter=CustomerLookUp/customerNo eq 'C-NARROW' with $expand=CustomerLookUp returns 200. That’s what gets the OData route down to 2 requests on the narrow filter.

Gotchas

  • GetItems returns your join without the projected columns. 200 OK, correct row count, columns missing, <ViewFields> present and correct. The filter still works, so if you only need the ids, it’s fine. If you need the values, use RenderListDataAsStream.
  • A projected field must also be in <ViewFields>. Leave it out and the column isn’t on the item. No error, no warning, no empty string, just absent. CAMLEX won’t add it for you either.
  • ProjectedFields is a whitelist, and it’s shorter than the docs let on. I provisioned one column of each disputed type and tried to project it. Text, Number, DateTime and Currency came through. Choice, person, yes/no, hyperlink and every flavour of multi-line text failed, including the plain one-line Note that Microsoft’s own list says is allowed. The error is Value does not fall within the expected range, which tells you nothing about which column it means. My workaround is to store the value in a plain text column, because I know that projects, and then put column formatting on it so the user still gets the coloured pill or the icon they’d have got from a choice column. Same experience in the list, and the column survives a join.
  • LookupId="TRUE" or you’re filtering on display text. Filter a lookup column without it and CAML compares against the shown value, not the id. You get zero rows and no error, which is the worst combination.
  • The In clause has two limits, not one. 500 values really is a hard cap: 500 returns, 501 throws Value does not fall within the expected range. The other one, repeated everywhere and traced to an archived 2013 article, is 60 values, past which SharePoint is said to stop treating the column as indexed so a big list throws the list view threshold error instead. My lists are 1,000 items, so I never met that one and I would not take it on faith. At 1,000 items my no-join arm went from 4 requests to 6 purely because of that chunking.
  • SharePoint REST pages at 100 by default, Graph at 200. Forget $top and a 1,000 item list is ten round trips before you’ve done anything interesting.
  • 999 is not a SharePoint number. It’s the directory-object ceiling from /users, and I paged a whole benchmark at it out of habit. Graph honours $top literally on list items: 999 gives 999, 1,001 gives 1,001. It also returns 200 OK for $top=100000 without honouring anything in particular, so a non-error tells you nothing. If you want to know your real page size, put more rows in the list than you think the cap is and count the first page.
  • The query string cap is 2,048 characters on SharePoint, and mine answered 401. Not 414, and not the 400 that ASP.NET’s own docs promise for this. You get “The length of the query string for this request exceeds the configured maxQueryStringLength value” under an Unauthorized status code, which sends you off debugging your token. Push much further past it and the status changes again, to 404. An escaped or clause costs about 43 characters, so plan on roughly 45 ids per request.
  • Graph’s ceiling is its own, and roughly twice SharePoint’s. 4,625 characters, so about 86 ids per request, and it announces the limit with a bare 404 and an empty UnknownError. If you chunk both stacks against one number you will silently hand Graph half the requests it needed, which is exactly what I did for the first draft of this post.
  • RenderListDataAsStream is not a drop-in swap for the OData shape. Rows under Row, not value. Every value is a string, ids included, so parse rather than cast. And you get the view’s own fields whether you want them or not: RenderOptions: 2 removed nothing for me, and the docs say why, since ListData is defined as “same as None”.
  • CAMLEX still works, and ToString() is not the method you want. Camlex.Client.dll 5.4.3 built and ran fine against CSOM on .NET 10, which I mention because the last NuGet release is from July 2024 and people ask. Use ToCamlQuery(); ToString() returns fragments, and SharePoint will run them and quietly leave your projections out.

Wrapping Up

The advice survives contact with a stopwatch, but not in the shape I brought to it. Two things didn’t survive. I’d been selling the join as a CSOM feature and it isn’t: CAML runs over REST too, and comes through intact on RenderListDataAsStream. And “the join always wins” is now false, because on REST a $batch of four ordinary queries beats the REST join on time and on bytes. What’s left is narrower and I think truer: the CAML join over CSOM is the fastest way to do this, and the only one that keeps the filter on the server without walking the chain backwards first.

The honest caveats: one tenant, one geography, warm caches, ten timed runs per approach, so anything inside about 20% is noise. Absolute numbers won’t be yours. The ratios probably will be.

And the gap grows with the ask. One customer, one row set, and the alternatives are merely slower. Three customers and 520 rows, and the reverse walks turn into thirteen and nine round trips, not because or doesn’t work but because 520 ids don’t fit in one query string on either stack.

Those are the numbers after I tuned every arm to the ceiling I measured for it: <In> at its 500-value cap, SharePoint’s or-chains at 2,048 characters, Graph’s at 4,500 just under its measured 4,625, Graph’s pages at a $top that actually returns the list. The first draft of this post had all three set to something I’d guessed, and every one of the guesses happened to favour the join. Fixing them cost the join some of its margin and cost me an afternoon, and it’s the only version of the table I’d defend.

My rule of thumb, now with receipts behind it and one honest amendment: if your lists are connected by lookup columns, make SharePoint do the join. If you can’t, batch. The round trips come back either way, but the join is the only thing that filters server-side without making you resolve the ids yourself first, and that’s the part you’d otherwise have written by hand in C# while holding an entire list in memory to do it.

Every number above is from lists of 1,000 items. Past 5,000 the rules change, one of these approaches starts returning partial answers with a 200 OK, and the REST filter trick stops working entirely. I ran the whole thing again at 10,000: what still works at the list view threshold.

References