← All essays

Engineering · Jul 2026 · 4 min read

LookupOrderedRows without the top N trap.

The function reads rows into AMPscript memory. Past a certain row count the render slows, then the send fails. Safer shapes when the data is bigger than the function was designed for.

LookupOrderedRows is one of the most useful functions in the AMPscript standard library. It reads rows from a Data Extension in a specific order, returns them as an array, and lets you loop over them inside a rendered email. Most personalization that involves more than a single value uses it.

It has a limit. The limit is not documented as a specific row count, because it depends on the size of each row, the complexity of the render, and how much other work the Journey Builder step is doing. Rough numbers we have seen hold across engagements: past a few thousand rows the render slows measurably. Past ten thousand, the send fails outright.

The failure mode is not a graceful degradation. The Journey step OOMs, the send status flips to error, and the recipients who were queued for the send never receive it. The recipients who received it before the failure got a valid email. Everyone else got nothing.

What actually breaks

AMPscript loads the full result set of LookupOrderedRows into memory before the loop starts. There is no streaming. A ten thousand row lookup with twenty columns per row is a payload of tens of megabytes held in the render context, then discarded when the render finishes.

For a small email, this is fine. For a marketing send to millions of recipients, each render is a fresh allocation of that memory. The infrastructure is not sized for the aggregate.

The specific symptoms in order: render times climb, then the send throughput drops, then the Journey step starts throwing Marketing Cloud internal errors, then eventually a subset of recipients simply do not send.

The safer shape: pre aggregate

The move that solves ninety percent of the cases is to pre aggregate the data into a lookup Data Extension keyed on the subscriber. Instead of asking AMPscript to read ten thousand order rows and pick the last five for this subscriber, you build a Data Extension called SubscriberRecentOrders keyed on subscriber ID, with the last five orders already in it as columns.

The AMPscript then does a single Lookup, gets the five orders, and renders them. The heavy work happened once in a SQL Query Activity that runs on a schedule, not once per rendered email.

The trade off is data freshness. The lookup Data Extension is as fresh as the last SQL Query Activity run. If you run it every fifteen minutes, the personalization lags by up to fifteen minutes. For most retention marketing, that is acceptable. For a real time transactional send, it is not, and the shape has to be different.

SQL Query Activities as the shaping layer

The pattern generalises. Almost any expensive AMPscript personalization has a cheaper SQL Query Activity that computes the same answer, writes it to a Data Extension, and lets AMPscript do a single row lookup at send time.

The Journey Builder step becomes cheap because the SQL step already did the work. The SQL step is easier to debug than the AMPscript block. The SQL step can be scheduled, monitored, and rerun independently. The AMPscript at render time collapses to a Lookup that takes microseconds.

The shift in shape is: move computation upstream, out of the render path, into a scheduled job that produces a rendering ready row per subscriber. This is a pattern email teams inherit from the data warehousing side of the house. It works because the same tradeoff applies: precompute the heavy answer, serve the cheap one at query time.

When you still need LookupOrderedRows

The function is not always avoidable. Some sends genuinely need the freshest possible read against a live Data Extension. When you do reach for LookupOrderedRows, keep the row count bounded. The safe range for a marketing send with a reasonable audience size is under a few hundred rows returned per subscriber. Under a hundred is comfortable.

If your query is naturally returning more, bound it in the call. LookupOrderedRows accepts an ordinal parameter that caps the number of rows returned. Use it. A LookupOrderedRows call that could theoretically return ten thousand rows but is capped at fifty behaves the same as a call that returns fifty by design.

And put a Try/Catch around any lookup that touches a Data Extension where the row count is not fully under your control. A silent failure at render time is a corrupted email. A caught failure is a fallback template that you designed on purpose.

“The function is not the problem. The pattern of asking it to do the work of a SQL query is.”

Keep reading

Engineering · Aug 2026

SSJS and external APIs: the token hygiene checklist.

Engineering · Aug 2026

AMPscript error handling: catching the silent failures.