Cart attributes carry page context into the order without touching the product itself
Line item properties attach to one product, cart attributes to the whole cart
The readback pattern re-displays saved values so you catch silent failures
Hidden inputs plus a visible confirmation block keep checkout data trustworthy
I needed a way to carry a customer's choice from the product page all the way into the order email, without changing the product and without a database. Cart attributes did it. The part nobody tells you is that passing the data is the easy half. Trusting it is the hard half.
What Cart Attributes Actually Are
Shopify gives you two places to stash custom text alongside a sale. Line item properties attach to a single product in the cart. Cart attributes attach to the whole cart and ride through to the order.
The difference matters more than it sounds.
If a customer is engraving a name onto one item, that's a line item property. It belongs to that line. But if they're telling me their delivery preference, their project name, or which campaign brought them in, that applies to the order as a whole. That's a cart attribute.
The mechanism is plain HTML. You add a form input with a name like attributes[project_name] and Shopify stores it on the cart. No JavaScript required for the basic version. When the order is created, that value shows up in the order's attributes, which means it lands in the admin, in webhooks, and in notification emails if you add it to the template.
Here is the smallest working example. Inside the product form or a cart form:
<input type="text" name="attributes[project_name]" value="{{ cart.attributes.project_name }}">
That value part is doing quiet, important work. It reads back whatever is already stored. So if the customer set it earlier and then came back to the cart, the field isn't empty. It shows what Shopify has.
I'll come back to that readback idea because it's the whole point of this post.
For reading the stored values anywhere in the theme, you walk cart.attributes:
{% for attribute in cart.attributes %}
{{ attribute.first }}: {{ attribute.last }}
{% endfor %}
One caveat. Cart attributes are global to the cart, so they get overwritten if you set the same key twice. There's no array of values per key. If you need per-product data, use line item properties instead.
I treat the choice as a single question: does this belong to the order, or to one thing in the order? That answer picks the tool every time.
I keep my section logic in theme files for exactly this reason. If you want the deeper context on why, see Why Every RAXXO Section Lives in Code, Not the Customizer.
Carrying Context From the Product Page
The product page is where the context is freshest. The customer just looked at the thing. They picked a variant, read the description, maybe made a choice that the standard variant system can't capture.
That's the moment to capture it.
I do it with a hidden input inside the product form. Say I want to record which section of the page the add-to-cart happened from, or a format preference that isn't a variant. I drop this into the form:
<input type="hidden" name="attributes[source_block]" value="product-hero">
Now every order that started from that button carries source_block: product-hero. No analytics tool needed to answer "which layout block drove this sale." It's on the order itself, forever.
For a value the customer types or selects, I use a visible field with the readback pattern baked in:
<label for="delivery-note">Delivery note (optional)</label>
<input
id="delivery-note"
type="text"
name="attributes[delivery_note]"
value="{{ cart.attributes.delivery_note | escape }}">
Notice the escape filter. Customer-typed text goes through escape before it hits the page again. Skip that and you've opened a hole where someone's input gets rendered as markup on the next load. One filter closes it.
There's a timing trap here too. If you set a cart attribute from the product form and the cart is empty, Shopify may not persist it until there's a line item. The safe order of operations is add the product and set the attribute in the same submit, which a product form does naturally when the inputs sit inside it.
For dynamic cases I use the AJAX cart API with a small fetch to /cart/update.js:
fetch('/cart/update.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attributes: { delivery_note: value } })
});
That updates attributes without a full page reload, which keeps the cart drawer snappy. I'm careful here because every extra fetch is a chance to hurt interaction speed. I covered what actually moves that metric in Six Months of Shopify Web Vitals Data.
The rule I hold myself to: capture context at the point it's obvious, not later when you're guessing.
The Readback Pattern That Keeps It Honest
I got something wrong the first time. I set attributes, assumed they stuck, and shipped. Then an order came through with a blank field that should have been filled. I had no way to know whether the customer left it blank or my code dropped it.
The fix is the readback pattern, and it's two parts.
Part one is the input readback I already showed: every field's value reads from cart.attributes. If the data saved, the field repopulates on reload. If it's empty on a reload after the customer typed something, I know the write failed, not the customer.
Part two is a visible confirmation block on the cart page. I print the stored attributes back to the customer in plain language before they check out:
{% if cart.attributes.delivery_note != blank %}
<p class="cart-note-readback">
Your note: {{ cart.attributes.delivery_note | escape }}
</p>
{% endif %}
This does two jobs at once. The customer sees that their input was captured, which builds trust at the exact moment they're about to pay. And I get a free visual test every time I place a test order. If the block is missing, the attribute didn't save, and I find out before a real customer does.
I test this the same way I'd test any edge case. I place an order with the field blank. I place one with special characters. I place one where I set the value, remove the product, and add it back, to confirm the attribute survives a cart that briefly emptied.
That last test caught a real bug for me once. The attribute cleared when the cart went to zero items. The readback block showed it blank. I rebuilt the flow to set the attribute on re-add.
Testing the quiet paths is a habit I learned the hard way. I wrote about that in The 404 Page I Almost Shipped Without Testing.
The readback pattern costs about six lines of Liquid. It turns an invisible system into one you can see working. For anything that rides through checkout, I won't ship without it.
Where the Data Lands and How I Use It
Capturing context is pointless if it dies on the order page. So I trace where it goes.
Cart attributes appear in the Shopify admin under the order's additional details. They're in the order object the API returns. They fire in the orders/create webhook payload under the note_attributes field (the API still uses the older name for these). And they can appear in customer-facing and staff emails if you add them to the notification Liquid.
That last one is my favorite use.
I add the saved attribute to the order confirmation template so the customer sees their choice echoed in the email they keep. For a project name or a delivery note, that reassurance is worth more than any feature copy. The email becomes a receipt of their intent, not just their payment. The email flow I send after a sale is its own small system, and I broke it down in The Email Every RAXXO Customer Gets After They Buy.
On the admin side, I use attributes for context I'd otherwise have to reconstruct. Which page block drove the add. Which optional preference the customer set. Any one-off instruction that doesn't fit a standard field.
A quick note on what cart attributes are not. They're not for structured catalog data that belongs to the product itself. Price tiers, specs, and reusable facts live in metafields, which are built for that and are far better at it. If you're deciding between the two, metafields hold facts about the thing and attributes hold facts about this particular purchase. I go through the metafield patterns I actually ship in Shopify Metafields Done Right.
One more practical limit. Cart attributes are plain strings. No types, no validation, no enforcement. If you need a number or a date, you validate it yourself before saving and you parse it carefully on the way out.
I keep attribute keys snake_case and documented in a comment at the top of the cart template so future me knows what each one means.
If you want a repeatable way to design these small systems with an assistant instead of guessing, the Claude Blueprint is how I plan this kind of work before touching the theme.
Bottom Line
Cart attributes are the cheapest way to move a customer's intent from the moment it exists to the order that captures it. No app, no database, a handful of Liquid.
If you only take one thing, take the readback. Setting an attribute is trivial and silent. Reading it back, into the input's value and into a visible block on the cart, is what turns it from hope into a thing you can see pass or fail.
Start small. Pick one piece of context you currently lose at checkout. Add a single attribute for it, add the readback block, and place three test orders including the blank and the special-character cases. Once you trust one, the pattern copies cleanly to the next.
If you're also sorting out which data belongs where, read the metafields piece next and keep the dividing line simple: attributes for this purchase, metafields for the product. That one distinction saves the most rework later.
This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)
Top comments (0)