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.
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
@ifor 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/@renderedAtstyle - [ ]
_messagecontext == "Send"gate wraps the entire side-effecting block; no writes outside it - [ ] Every
LookupandAttributeValuehas anEmpty()default - [ ] Every
UpsertDatadestination DE has its primary key verified - [ ] Every
LookupRowsresult is bounded (you expect under 2000 and assert, or you've pre-shaped upstream) - [ ] Every
UpdateSingleSalesforceObject/CreateSalesforceObjectis followed by anInsertDatalog row tode_log_sf_writes - [ ] Every CloudPage
RequestParametervalue passes throughHTMLEncodebefore rendering to HTML - [ ]
Base64EncodeandBase64Decodeflags match within the same change, with a comment linking them - [ ] Math on string input is preceded by
IsNumeric(or input is pre-validated upstream) - [ ]
SubstringandIndexOfuse 1-based positions - [ ]
Concatused for string building; no+operator attempts - [ ]
===-equivalent comparisons forced viaLowercase()/ explicit coercion when type matters - [ ] No
TreatAsContenton user-controlled input - [ ] No
MD5used as a signature;HMACSHA256or 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
- Marketing Cloud principles from production — the meta-rules above the AMPscript specifics
- MC AMPscript gotchas — the failure shapes this Style Guide is designed to prevent
- MC SQL Style Guide — the sibling discipline page for SQL Activities
- MC SSJS Style Guide — the sibling discipline page for SSJS scripts
- Every reference page in this catalog — Basics · String · Date · Math · Validation · Data Extension · Subscriber + Profile · Cloud-write · Encoding + Hashing
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.