Skip to main content

Marketing Cloud AMPscript: Style Guide

The opinionated rules Cleon applies to every AMPscript block we ship in Marketing Cloud — naming, formatting, commenting, patterns to prefer, anti-patterns to refuse — distilled from the gotchas and reference pages into a single discipline document. Mirrors the SQL and SSJS Style Guides.

Decision framework·Last updated 2026-05-19·Drafted by Lira · Edited by German Medina

This is the page where Cleon stops describing what AMPscript is and starts saying what we do with it. Salesforce defines what works. The reference pages document the function surface. The gotchas document what fires at render time. This Style Guide is the discipline that keeps an email or a CloudPage maintainable a year after we hand it off.

Use it as a checklist before merging any new AMPscript block into production. The rules are short on purpose — when a rule needs an explanation, the explanation is in the page it links to.

Naming

Local variables follow a consistent convention

Decided once, before the first content block is written. AMPscript variables are prefixed with @; the rest of the name follows the project's case convention.

| Convention | Looks like | When | |---|---|---| | @camelCase | @firstName, @subscriberKey, @orderTotal | Default for new projects | | @PascalCase | @FirstName, @SubscriberKey | Legacy MC tenants where DE columns use PascalCase | | @snake_case | @first_name, @subscriber_key | Some teams aligning with SQL conventions |

The pattern: pick one, document it in this project's repo, never mix. The hand-off failure is reading a block and not knowing whether @FirstName is the same conceptual thing as @firstName from a different block — the next person assumes they are, refactors, and either breaks the personalization or duplicates the work.

Variables carry intent

A variable called @subscriberKey carries the decision; a variable called @k carries nothing. Always name variables after what they hold, not after the loop index they live in.

%%[
  /* AVOID */
  FOR @i = 1 TO RowCount(@r) DO
    SET @k = Field(Row(@r, @i), "k")
  NEXT @i

  /* PREFER */
  FOR @i = 1 TO RowCount(@rows) DO
    SET @subscriberKey = Field(Row(@rows, @i), "SubscriberKey")
  NEXT @i
]%%

@i is fine as a numeric index. @r and @k are not, even with a nearby comment.

Data sources named distinctly

The three sources AMPscript reads from — DE columns, Subscriber Attributes, local variables — must be named differently when they hold the same conceptual value. See gotchas — #4 for the full rationale.

| Source | Naming convention | |---|---| | Sendable DE column | DE schema name as-is (FirstName) | | Subscriber Attribute (profile) | Prefix or suffix to disambiguate (ProfileFirstName) | | Local variable | @-prefixed local (@firstName) |

The Cleon convention: when a DE column and a Subscriber Attribute hold the same logical value, rename the Subscriber Attribute (or never use it). Don't rely on AMPscript's implicit resolution rules.

DE naming aligns with SQL Style Guide

The DE prefix convention from the SQL Style Guide applies to AMPscript-written DEs as well — de_log_* for log writes, de_stg_* for staging, de_email_* for sendable / pre-shaped email-context DEs. AMPscript scripts are heavy DE consumers; consistent naming across SQL Activities and AMPscript reads is the discipline that scales.

Formatting

Block delimiters on their own lines

%%[
  /* multi-line logic — open and close on their own lines */
  SET @firstName = AttributeValue("FirstName")
  IF Empty(@firstName) THEN
    SET @firstName = "Friend"
  ENDIF
]%%

Single-line blocks are fine for trivial one-liners (%%[ SET @x = 1 ]%%), but anything with branching, lookups, or multiple SETs goes vertical.

2-space indent, never tabs

Same reason as SQL and SSJS: tabs render differently across the AMPscript editor, GitHub diffs, and our docs. Two spaces is the convention.

Inline forms — %%=v(@x)=%% for locals, %%[Col]%% for DE columns

There is no ambiguity if you stick to two forms:

| What you want to render | Use | |---|---| | Local variable set in an AMPscript block | %%=v(@x)=%% (inline) or OUTPUT(@x) (inside a block) | | DE column from the sendable DE | %%ColName%% (inline, no @) or %%[ColName]%% (bracket form, also inline) | | Subscriber Attribute (profile) | AttributeValue("AttrName") always; never the bare inline form |

Avoid %%@x%% for locals — it doesn't resolve local variables in many cases (see gotchas — #1).

IF / ELSE / ENDIF indented at 2 spaces per level

%%[
  IF Empty(@tier) THEN
    IF NOT Empty(@country) THEN
      SET @tier = "RegionDefault"
    ELSE
      SET @tier = "Standard"
    ENDIF
  ENDIF
]%%

ENDIF aligns with the IF that opens it. Same for NEXT aligning with FOR. Without the alignment, deeply-nested branching becomes unreadable inside a block.

Write functions formatted with pair-per-line

InsertData / UpdateData / UpsertData take pairs of arguments. Vertical formatting makes the alignment visible to code review.

%%[
  /* AVOID — easy to miscount or misalign */
  InsertData("de_log_writes", "RunId", @runId, "Step", "process", "Message", "completed", "Ts", Now())

  /* PREFER — pair-per-line; the structure is the validation */
  InsertData(
    "de_log_writes",
    "RunId",    @runId,
    "Step",     "process",
    "Message",  "completed",
    "Ts",       Now()
  )
]%%

See Data Extension functions for the full pattern.

Magic strings pulled into constants at the top

DE names, column names, status values, locale strings — pull them into named locals at the top of the script. A rename becomes one change instead of fifteen.

%%[
  SET @DE_SUBSCRIBERS = "master_subscribers"
  SET @STATUS_ACTIVE  = "Active"
  SET @DEFAULT_TIER   = "Standard"

  SET @tier = Lookup(@DE_SUBSCRIBERS, "Tier", "SubscriberKey", _subscriberKey)
  IF Empty(@tier) THEN
    SET @tier = @DEFAULT_TIER
  ENDIF
]%%

Capture Now() once at the top of the email

Now() is evaluated per-block per-recipient. For consistency across multiple log writes and rendered timestamps within the same email, capture once and reuse — same RUN_ID-style discipline as SSJS scripts.

%%[
  SET @renderedAt = Now()

  /* Every subsequent reference uses @renderedAt, not Now() */
]%%

Commenting

Don't comment what the code does — comment why

The code says what. Comments add the context the code can't carry.

%%[
  /* POINTLESS — repeats what the code already says */
  /* Get active subscribers tier */
  SET @tier = Lookup("master_segments", "Tier", "SubscriberKey", _subscriberKey)

  /* USEFUL — explains the decision behind the default */
  /* Default to Standard tier when the segment row is missing — happens
     during the first 24h after sign-up, before the nightly enrichment
     job populates de_log_segments. */
  SET @tier = Lookup("master_segments", "Tier", "SubscriberKey", _subscriberKey)
  IF Empty(@tier) THEN
    SET @tier = "Standard"
  ENDIF
]%%

Comment the receipt for non-obvious choices

When you cut a corner, comment it with the date and the reason. The next dev (often you, six months later) needs the receipt.

%%[
  /* TEMPORARY — replace by 2026-06-15 (calendar reminder set 2026-05-19)
     Reason: the segments DE column "Tier" is being renamed to
     "TierName" in PR #142; this script reads the old column until
     the migration lands. */
  SET @tier = Lookup("master_segments", "Tier", "SubscriberKey", _subscriberKey)
]%%

See Marketing Cloud principles — #11 for the temporary-code discipline.

Document the source of every cross-context value

When reading from a DE column or a Subscriber Attribute, comment the source so the next person doesn't have to grep across the project to find where the value originates.

%%[
  /* Source: de_email_<send>_subscriber.Tier — populated by the
     audience-build SQL Activity that runs upstream */
  SET @tier = AttributeValue("Tier")

  /* Source: All Subscribers profile attribute, manually maintained
     by the customer success team in Email Studio */
  SET @lifecycleStage = AttributeValue("LifecycleStage")
]%%

Patterns to prefer

_messagecontext == "Send" gate around every side-effecting block

Every InsertData, UpdateData, UpsertData, DeleteData, UpdateSingleSalesforceObject, CreateSalesforceObject, and ClaimRow call goes inside a single perimeter gate. See Cloud-write functions and Subscriber + Profile functions.

%%[
  IF Lowercase(_messagecontext) == "send" THEN
    /* All side effects go here */
  ENDIF
]%%

Empty() and a default at every Lookup and AttributeValue read

Both functions return NULL silently when nothing matches. Every read defaults. See gotchas — #2 and Validation functions.

HTMLEncode on every CloudPage user-input render

CloudPages that read RequestParameter or any external-source value pass that value through HTMLEncode before rendering to HTML. See Encoding + Hashing functions.

Match Base64Encode and Base64Decode flag values

When you ship Base64Encode with a flag, ship the matching Base64Decode with the same flag in the same change, with a comment linking them. See Encoding + Hashing functions.

Pre-shape data upstream in de_email_* DEs

Heavy LookupRows, multi-DE joins, aggregations, and per-recipient lookups all belong in the audience-build SQL Activity. AMPscript reads a thin, pre-shaped DE. See Data Extension functions and SQL Style Guide.

Use _subscriberKey not _emailaddress for joins

Email addresses change; subscriber keys are intended to be stable. See Subscriber + Profile functions.

Log every Cloud-write result to de_log_sf_writes

Every UpdateSingleSalesforceObject / CreateSalesforceObject call is followed by an InsertData that records the JobID, SubscriberKey, SF object, operation, and @result. Audit after every Send. See Cloud-write functions.

Concat for string building — never +

AMPscript has no + operator for strings. See String functions.

Substring and IndexOf are 1-based — never 0 as start

%%[ SET @firstChar = Substring(@firstName, 1, 1) ]%%   /* NOT 0, 1 */

See String functions and gotchas — #7.

IsNumeric gate before any math on string input

Math functions silently coerce non-numeric strings to 0. Validate first. See Math functions and Validation functions.

Verify destination DE primary key before any UpsertData reaches production

UpsertData returns 1 for both inserts and updates. The wrong PK silently duplicates rows. See Data Extension functions and gotchas — #4.

Upper-bound every FOR loop expected to iterate over a LookupRows result

LookupRows caps at 2000. If your loop assumes more, it's already wrong. Add an explicit assertion that RowCount is under 2000, or pre-shape upstream. See gotchas — #3.

Patterns to refuse

UpsertData against a DE whose primary key you haven't verified

Silent duplicates. The single most expensive operational pattern. See Data Extension functions and gotchas — #4.

Side-effecting code outside a _messagecontext == "Send" gate

Every preview-render writes to production. Code review rejects any DE write or Cloud-write that doesn't sit inside the gate. See Cloud-write functions.

Lookup or AttributeValue without Empty() defaulting

Render-time NULLs leave blanks in the email body. Never trust the result of either function without a default.

LookupRows as an "all rows" iterator

Silently caps at 2000. Either explicitly assert the cap and alert when it fires, or pivot to a SQL Activity. See gotchas — #3.

TreatAsContent on user input

Template injection. Whitelist-only. See Encoding + Hashing functions.

HTMLEncode skipped on any CloudPage user-input render

XSS exposure. Every RequestParameter and every external-source value through HTMLEncode. See Encoding + Hashing functions.

MD5 as a signature

It is not a signature. Use HMACSHA256(payload, key) if available, otherwise do the crypto outside MC. See Encoding + Hashing functions.

Substring(@s, 0, ...) (0-based assumption from JS)

Returns wrong / empty result silently. AMPscript is 1-based. See String functions and gotchas — #7.

Math on string input without IsNumeric validation

Silent coercion of bad input to 0. Wrong math output, no warning. See Math functions.

%%@x%% for local variables

Doesn't resolve in many contexts. Use %%=v(@x)=%% instead. See gotchas — #1.

Replace assuming "first match only" (JS habit)

AMPscript replaces all occurrences by default. Pass the 4th arg to cap. See String functions.

== against mixed types without Lowercase / explicit coercion

Loose-typed comparisons sometimes match values you didn't intend. See gotchas — #6.

AMPscript with more than ~30 lines in a single email

The work belongs upstream in SQL. AMPscript should be thin: read columns, interpolate, log. See Data Extension functions.

Trusting personalization preview as final QA

Preview ≠ send. Real test send to a real subscriber in a staging Business Unit is the only true verification. See gotchas — #9.

The discipline check before merging

Before any new AMPscript block goes from draft into a published email or activated CloudPage, walk through this checklist:

  • [ ] Local variable naming follows the project convention (one of @camelCase / @PascalCase / @snake_case, never mixed)
  • [ ] Every variable holds a meaningful name, not a single letter (except @i for loop indices)
  • [ ] DE / column / Subscriber Attribute names are distinct enough that there is no ambiguity about which source is being read
  • [ ] Magic strings (DE names, status values, locales) pulled into constants at the top
  • [ ] Now() captured once at the top and reused; RUN_ID / @renderedAt style
  • [ ] _messagecontext == "Send" gate wraps the entire side-effecting block; no writes outside it
  • [ ] Every Lookup and AttributeValue has an Empty() default
  • [ ] Every UpsertData destination DE has its primary key verified
  • [ ] Every LookupRows result is bounded (you expect under 2000 and assert, or you've pre-shaped upstream)
  • [ ] Every UpdateSingleSalesforceObject / CreateSalesforceObject is followed by an InsertData log row to de_log_sf_writes
  • [ ] Every CloudPage RequestParameter value passes through HTMLEncode before rendering to HTML
  • [ ] Base64Encode and Base64Decode flags match within the same change, with a comment linking them
  • [ ] Math on string input is preceded by IsNumeric (or input is pre-validated upstream)
  • [ ] Substring and IndexOf use 1-based positions
  • [ ] Concat used for string building; no + operator attempts
  • [ ] ===-equivalent comparisons forced via Lowercase() / explicit coercion when type matters
  • [ ] No TreatAsContent on user-controlled input
  • [ ] No MD5 used as a signature; HMACSHA256 or outside-MC crypto when tamper-resistance matters
  • [ ] AMPscript per email is under ~30 lines; heavier logic moved to SQL Activity upstream
  • [ ] Verified via real test send to a staging Business Unit, not just preview
  • [ ] Comments explain the why of any non-obvious choice
  • [ ] Any "temporary" code has an expiration date and a calendar reminder

When all twenty-two fire, the block is ready to ship.

Related

Catalog progress: with this Style Guide, all 9 reference + decision-framework pages in the AMPscript section are shipped, alongside the gotchas production-note. The remaining catalog work is 3 how-to debugging snippets.

If you spot a rule missing — or one of these rules being violated in our public work — write to hello@wearecleon.com. We add it, or we fix it and we say so.