A Shopify line item property is a name and value pair attached to one line in the cart. The theme collects it with an input named properties[Name] inside the product form, Liquid reads it back as line_item.properties, and it reaches the order as a customAttributes entry on the line item. A leading underscore hides it from the customer at checkout.
That is the whole mechanism. Four things catch people out with it: lines splitting when two customers personalise the same variant differently, hidden properties that your own cart page still prints, change.js wiping properties you did not resend, and finding the data again after checkout under a different name.
How do you add a line item property in the product form?
Put an input inside {% form 'product', product %} with a name of properties[...]. Whatever sits inside the brackets becomes the property name the customer, the merchant and every API will see, so write it as a label, not a variable name. Shopify’s product template docs give monogram and engraving text as the canonical use, and file uploads as the other.
{% form 'product', product, id: 'product-form' %}
<input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
<label for="engraving">Engraving (up to 20 characters)</label>
<input type="text" id="engraving" name="properties[Engraving]" maxlength="20">
<label for="gift-wrap">
<input type="checkbox" id="gift-wrap" name="properties[Gift wrap]" value="Yes"> Add gift wrap
</label>
<input type="hidden" name="properties[_source]" value="pdp">
<input type="number" name="quantity" min="1" value="1">
<button type="submit">Add to cart</button>
{% endform %}
Three things in there are deliberate. The checkbox has an explicit value, because an unchecked box sends nothing and a checked box with no value sends on, which is what the merchant would then read on the order. The _source property starts with an underscore, which is the hiding convention covered below. And the inputs are plain HTML inside the form, with no JavaScript required: the form posts to /cart/add and the properties travel with it.
Why do identical variants end up on separate lines?
Because properties are part of the line’s identity. The line_item reference describes line_item.key as the variant ID, a colon, and “a hash of unique characteristics of the line item”, and says the key changes as those characteristics change, properties included. The cart template docs put it plainly: two of the same item with different line item properties “will be split into separate line items”.
So a variant with Engraving: Mum and the same variant with Engraving: Dad are two lines of quantity 1, and the cart page’s quantity controls have to address them by key, since in cart.items the line_item.id is the variant ID and both lines share it.
The same rule explains a quieter bug. A hidden property whose value differs between two adds of the same variant, a timestamp or a session ID say, splits them into two lines the customer can see no difference between. Keep hidden values constant per variant, or do not set them from the product page at all.
How does an underscore hide a property?
Start the property name with _ and Shopify hides it from the customer at checkout. That sentence comes from the line_item reference: “You can add an underscore to the beginning of a property name to hide it from customers at checkout.” Apps lean on it constantly, which is why a store with a few of them installed ends up with underscored properties on every line.
Note the scope. Checkout hides them. Your cart page does not, unless the theme’s loop skips them, and the loop in Shopify’s own cart template example prints every property. Filter it yourself:
{% for item in cart.items %}
{% unless item.properties == empty %}
<dl>
{% for property in item.properties %}
{% assign first_char = property.first | slice: 0 %}
{% if first_char == '_' or property.last == blank %}
{% continue %}
{% endif %}
<dt>{{ property.first }}</dt>
<dd>
{% if property.last contains '/uploads/' %}
<a href="{{ property.last }}">{{ property.last | split: '/' | last }}</a>
{% else %}
{{ property.last }}
{% endif %}
</dd>
{% endfor %}
</dl>
{% endunless %}
{% endfor %}
The /uploads/ branch is lifted from Shopify’s example: a file upload property holds a URL, and a link reads better than the raw path. Hidden properties are still saved to the order and still appear in the admin order view; the docs offer no switch for hiding them there.
Cart attributes have their own, stronger version of this. The cart object docs say a double underscore prefix makes an attribute private, and private attributes “can’t be read from Liquid or the Ajax API”, which Shopify suggests for data that does not affect rendering so the page can be cached more aggressively. The docs describe that for cart attributes (attributes[__name]), not for line item properties.
How do you send properties through the Ajax Cart API?
POST /cart/add.js takes an items array, and each item takes an optional properties object of key and value pairs, per the Cart API reference. Pass the hidden ones the same way, underscore and all.
const root = window.Shopify.routes.root;
async function addEngraved(variantId, engraving) {
const response = await fetch(`${root}cart/add.js`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
items: [{
id: variantId,
quantity: 1,
properties: { Engraving: engraving, _source: 'pdp' }
}]
})
});
if (!response.ok) {
// 422 with { status, message, description }, message is "Cart Error"
const error = await response.json();
throw new Error(error.description ?? error.message);
}
return response.json();
}
change.js is where people lose data. It accepts properties, and the docs warn that whenever a request includes properties it “overwrites the entire properties object”. Changing one value means sending all of them back, hidden ones included. Identify the line with id set to the line item key (the docs accept either the variant ID or the key there) or with line, the 1-based position. Given what the previous section said about shared variant IDs, use the key.
And after add.js you still do not know what the cart looks like, since the response is the items you added, not the cart. Bundled section rendering fixes that, and our Section Rendering API post covers the sections parameter in detail.
When should it be a cart attribute instead?
When the information belongs to the order rather than to one line. A gift message for the whole parcel, a delivery date, a “how did you hear about us” answer. The cart template docs show the pattern: an input named attributes[Delivery date] with form="cart" so it can sit anywhere on the page and still submit with the cart form, and a <textarea name="note" form="cart"> for the free-text note. Over Ajax, POST /cart/update.js takes { attributes: { 'Delivery date': '2026-12-20' } } and { note: '...' }.
Keep one note field. The cart docs say that if {{ cart.note }} appears several times on the page, the one latest in the DOM is what gets submitted.
Where do properties end up after checkout?
Everywhere, under a different name. In the Admin GraphQL API (2026-10), LineItem.customAttributes is [Attribute!]!, and Attribute is key: String! plus value: String. Order.customAttributes holds the cart attributes the same way. Draft orders, abandoned checkouts and subscription contracts all use the same type.
query OrderProperties($id: ID!) {
order(id: $id) {
customAttributes { key value }
lineItems(first: 50) {
nodes {
title
quantity
customAttributes { key value }
}
}
}
}
Headless storefronts set them on the way in. The Storefront API’s CartLineInput.attributes is [AttributeInput!] with the same key and value shape, capped at 250 per line per the CartLineInput reference, and CartLine.attribute(key: String!) reads one back.
Shopify Functions see them too. In the Cart Transform API input schema a cart line exposes attribute(key: "...") { value }, and the docs say outright that “cart line attributes are equivalent to the line_item object in Liquid”. That is how a property set on the product page can drive a Function that merges lines or rewrites a title, and how a cart validation Function can refuse checkout when a required property is missing.
When would we use a property, and when not?
Use a line item property when the value belongs to one line and the customer typed it or chose it: engraving, a size note, an uploaded file. Prefix it with an underscore the moment it is for your code rather than the customer, filter underscored names in your own cart loop, and keep hidden values constant per variant so they never split lines. Move anything order-wide into a cart attribute. And if the value needs validating or pricing, do not trust the property on its own, because it is a string a browser sent; pair it with a Function that reads attribute(key:) and enforces the rule.
We would not use properties as a general-purpose data store. They are visible in the admin for the life of the order, they cannot be hidden there, and change.js will erase them the first time a quantity update goes out without resending them.
Whoooop builds Shopify themes and the apps around them, and personalisation flows like this one come up often: the product form, the cart loop, and the Function that checks what arrived. If you want a hand with a storefront, our Shopify development page covers how we work.