Skip to main content

MC AMPscript gotchas: what survives a hand-off

AMPscript looks like a simple template language. The reality at scale is three different variable syntaxes, three NULL checks that mean different things, a lookup API that fails silent, and a preview that doesn't render the same code path as the send. Ten gotchas anchored to the next person inheriting your email.

Production note·Last updated 2026-05-13·Drafted by Lira · Edited by German Medina

AMPscript looks like a simple template language — %%=v(@firstName)=%% interpolates a value, %%[ ]%% runs logic, done. The reality at scale is a language with three different ways to write a variable, a NULL surface that has three different functions for "nothing here", a lookup API that fails silent, and a personalization preview that uses a different render path than the actual send.

The hand-off framing isn't decoration. The ten items below are the ones the next person inheriting an AMPscript email — or the same person six months later — needs to know before changing anything, because none of them surface as errors until production lights up at 11pm and a Send Activity reports Completed while half the recipients got a blank greeting.


The gotchas

1. Three variable syntaxes, and the one that silently fails to interpolate

There are several inline forms:

  • %%=v(@firstName)=%% — resolves a local variable set in an AMPscript block. The durable form.
  • %%firstName%% — resolves a DataExtension attribute from the sendable DE (no @).
  • %%@firstName%% — also tries to resolve from the sendable DE; does not resolve local variables you set in %%[ ]%% blocks.

The hand-off failure: someone reads %%@firstName%% and assumes it's reading the local var defined three blocks up. It's not — it's reading the DE column named firstName, and if that column doesn't exist on the sendable DE, it renders blank.

%%[
  SET @firstName = "Mariana"
]%%

<!-- BLANK — @firstName is not a DE attribute -->
Hello, %%@firstName%%

<!-- "Mariana" — v() resolves the local variable -->
Hello, %%=v(@firstName)=%%

Pick one form per project. The Cleon convention: %%=v(@x)=%% for local variables, %%[Column]%% (with bracket syntax inside an AMPscript block) for DE columns. Don't mix.

2. Lookup() returns NULL when nothing matches — no error

A Lookup() against a DE returns the column value when the filter matches a row, or NULL when nothing matches. The function never throws. An email body that interpolates the result renders blank where the value was supposed to be.

%%[
  SET @segment = Lookup("master_segments", "Tier", "SubscriberKey", _subscriberKey)
]%%

<!-- If no row matches, @segment is NULL — the email renders
     a blank where you expected "Gold" or "Silver" -->
You belong to the %%=v(@segment)=%% tier.

The defensive pattern is Empty() and a default, every time:

%%[
  SET @segment = Lookup("master_segments", "Tier", "SubscriberKey", _subscriberKey)
  IF Empty(@segment) THEN
    SET @segment = "Standard"
  ENDIF
]%%

Same fingerprint as the SSJS Platform.Function.LookupRows no-error-on-empty failure mode — see MC SSJS gotchas — #5.

3. LookupRows() is silently capped at 2000 rows

Like Platform.Function.LookupRows at 2500 in SSJS, AMPscript's LookupRows() caps at 2000. Email logic that loops over the result misses everyone past row 2000 and the email sends with the truncated list, no warning.

%%[
  SET @rows = LookupRows("active_promotions", "Region", "LATAM")
  SET @n = RowCount(@rows)
  
  /* @n is at most 2000 — if there are 5000 LATAM promos in the DE,
     you sent the first 2000 to everyone and ignored the rest */
]%%

The defense isn't inside AMPscript: it's upstream. Have a SQL Activity write a de_email_<purpose> DE that's already capped and shaped for the email's needs (TOP 100 with the right ordering), and have AMPscript read that pre-shaped DE. Don't ask AMPscript to scope down for you.

4. AttributeValue() reads Subscriber Attributes — not DE columns or local variables

The three "where do attributes come from" sources are easy to confuse:

  • DE columns of the sendable DE: %%[Column]%% inside an AMPscript block, or %%Column%% inline.
  • Local AMPscript variables: %%=v(@var)=%% inline, @var inside a block.
  • Subscriber Attributes defined in Email Studio's All Subscribers profile: AttributeValue("AttrName").

These three sometimes overlap — a column called FirstName on the sendable DE, a Subscriber Attribute also called FirstName, a local @FirstName. Which one wins in %%FirstName%% depends on context and is one of the longest-running silent-bug sources in MC.

%%[
  /* This reads from the **Subscriber Attribute** definition in
     Email Studio. NOT the sendable DE's column with the same name. */
  SET @profileRegion = AttributeValue("Region")
]%%

The hand-off rule: name the three differently. [DERegion] for the column, @LocalRegion for the local, AttributeValue("ProfileRegion") for the Subscriber Attribute. The naming prevents the confusion.

5. AMPscript blocks evaluate in document order — but content-block inheritance breaks the model

In a single %%[ ]%% block, statements run top-to-bottom. Across blocks within one content area, also top-to-bottom. Across content blocks in a template-based email, the order is not always what the WYSIWYG shows. Content blocks can render in a different order than they appear visually because of template-region inheritance rules and the order MC's renderer walks the template tree.

The hand-off failure: someone sets @discount in content block A (visually first), block B reads @discount and renders it (visually second). Months later a template change moves block B into a different parent region and now it renders before block A. @discount is NULL, the email renders blank, nobody notices for a week.

%%[
  /* DURABLE — set every variable at the top of every block where
     it's read, with a Lookup or default. Don't rely on
     cross-block variable propagation. */
  SET @discount = Lookup("de_promotions", "Amount", "SubscriberKey", _subscriberKey)
  IF Empty(@discount) THEN
    SET @discount = 0
  ENDIF
]%%

Treat every content block as its own scope. Re-fetch what you need.

6. Comparisons are loose-typed and case-insensitive by default

%%[
  IF @status == "Active" THEN
    /* matches "Active", "ACTIVE", "active", and " Active " (trimmed) */
  ENDIF
]%%

For most personalization this is fine. The traps:

  • A trailing space in the data ("Active ") still matches "Active" — == trims and case-folds before comparing. If your downstream logic expects the literal "Active" with no whitespace, the comparison passes and the next step fails.
  • Comparing a date string against a date value — implicit conversion, can match in surprising ways.

When the type matters, anchor explicitly: force lowercase with Lowercase(@status) before comparing, or parse both sides with DateParse() for dates. Explicit beats the implicit conversion every time.

7. Substring() and IndexOf() are 1-based, not 0-based

Substring("Hello", 1, 4) returns "Hell", not "ello". Anyone coming from JavaScript, Python, or Go reaches for Substring(@s, 0, n) and gets the wrong result silently.

%%[ 
  /* Take the first character of the first name */
  SET @firstChar = Substring(@firstName, 1, 1)  /* NOT 0, 1 */
]%%

Same for IndexOf() — returns 1-based position, or 0 if not found (0 means "not found", 1 means "found at the first character"). The first-name-trimmed bug ("ariana" instead of "Mariana") is the single most common AMPscript artifact in production sends.

8. Empty() vs IsNull() vs == "" — three concepts for "nothing here"

The three functions behave differently:

  • Empty(@x) — true for NULL, empty string, or unset variable. The broadest check.
  • IsNull(@x) — true only for NULL.
  • @x == "" — true only for empty string after AMPscript's loose-typed comparison.
%%[
  SET @firstName = AttributeValue("FirstName")
  
  /* DURABLE — covers NULL, "", and "unset" in one check */
  IF Empty(@firstName) THEN
    SET @firstName = "Friend"
  ENDIF
]%%

Use Empty() by default. Reach for IsNull() only when you specifically need to distinguish NULL from empty string (rare in AMPscript context). Avoid == "" — it doesn't cover the NULL case and the next person inheriting the code will think it does.

9. Personalization preview uses a different code path than the actual send

The Email Studio preview:

  • Reads _subscriberKey from the chosen test send target.
  • Reads DE columns from the sendable DE row of the test subscriber.
  • Reads AttributeValue() from the Subscriber Attribute values of the test subscriber.
  • Does not run send-time link wrapping, full tracking instrumentation, or some functions that depend on the actual JobID.

A preview that renders correctly is necessary, but not sufficient. The send fails when:

  • A Lookup() against a DE that the preview's test subscriber doesn't exist in returns NULL in production for that subscriber.
  • AMPscript that branches on _jobid does something different in preview.
  • An AMPscript function that depends on the rendered subscriber's actual profile data gets a different value at send-time.

Defense: send a real test send to a real subscriberKey (a staging audience in a staging Business Unit) before sending to production. The test send runs the same renderer the production send does. Preview is for fast iteration; test send is for confidence.

10. UpdateSingleSalesforceObject and Cloud-write functions can fail silently

AMPscript functions that write back to Sales / Service Cloud (UpdateSingleSalesforceObject, CreateSalesforceObject, etc.) return a status value — but they don't throw on failure. A wrong ID, a permission issue, a locked record — the email body renders fine, the cloud object isn't updated, and the failure surfaces a week later when sales asks why the lead status is wrong.

%%[
  SET @result = UpdateSingleSalesforceObject(
    "Lead", @leadId,
    "Status", "Engaged"
  )
  
  /* Log every call to a DE — @result is 1 on success, 0 on failure */
  InsertData(
    "de_log_ampscript_writes",
    "JobID", jobid,
    "SubscriberKey", _subscriberKey,
    "Operation", "Lead.Status=Engaged",
    "Result", @result,
    "Ts", Now()
  )
]%%

@result = 0 means the write didn't happen. Audit de_log_ampscript_writes after every Send. The pattern is the same as the SSJS rule about logging inside catch blocks — silent failure is only acceptable after the failure has been recorded somewhere.


Closing

These ten are the failure shapes that ate the most production hours when Cleon was inheriting AMPscript from other teams or growing email programs past their first few sends. The pattern across them all is the same as in SQL and SSJS: AMPscript reports success a lot of the time when the thing you intended didn't happen, and the only defense is instrumentation + explicit defaults + treating preview as necessary-but-not-sufficient.

If you spot a gotcha that bit your team and isn't here — write to hello@wearecleon.com. We add it, with credit.

Reference: