In the Shopify Architecture Guide, we cover the fundamentals. Today we look at how you model data in Shopify -- the foundation of every good architecture.
At smplx., we've experimented with various approaches. Metafields? Metaobjects? Custom apps with separate databases? Each option has its place. Let's make the right decision together.
The Shopify Data Model
Shopify has a rigid but intelligent data model:
Shop
|-- Products
| |-- Variants
| +-- Metafields (since 2021)
|-- Collections
| +-- Metafields
|-- Customers
| +-- Metafields
|-- Orders
| +-- Metafields
+-- Metaobjects (since 2024)
+-- Fields & Relationships
The Standard Entities
Products are the centerpiece:
- Title, Description, Vendor
- Published Status
- Collections (many-to-many relationship)
- Variants (one-to-many relationship)
Variants are the SKUs:
- Price, SKU, Barcode
- Inventory
- Options (Size, Color, etc.)
Collections are categories:
- Title, Description
- Products (many-to-many)
But what do you do when you need more? That's where metafields come in.
Metafields: Attaching Custom Data
What are Metafields?
Metafields are key-value pairs that you can attach to any standard entity.
Example: Product with Additional Properties
query GetProduct($id: ID!) {
product(id: $id) {
id
title
metafields(first: 10) {
edges {
node {
id
key
value
type
}
}
}
}
}
Metafield Types
Shopify provides predefined types:
| Type | Example | Usage |
|---|---|---|
single_line_text |
"Cotton" | Material |
multi_line_text |
"Long description" | Detailed info |
number_integer |
"100" | Quantity |
number_decimal |
"19.99" | Price addon |
date |
"2026-02-19" | Availability date |
date_time |
"2026-02-19T10:30" | Timestamp |
boolean |
true/false | Yes/No flags |
json |
{...} |
Arbitrary structure |
list.single_line_text |
["red", "blue"] | Array of text |
list.number_integer |
[10, 20] | Array of numbers |
url |
"https://..." | Links |
file_reference |
File ID | File access |
Metafield Examples
Example 1: Material Info for Product
mutation CreateProductMetafield($id: ID!, $metafield: MetafieldsSetInput!) {
metafieldsSet(ownerId: $id, metafields: $metafield) {
metafields {
id
key
value
}
}
}
Input:
{
"id": "gid://shopify/Product/123",
"metafield": {
"key": "material_composition",
"value": "{\"cotton\": 85, \"polyester\": 15}",
"type": "json"
}
}
Example 2: Low Stock Warning
{
"key": "low_stock_threshold",
"value": "10",
"type": "number_integer"
}
Metafields in the Theme (Liquid)
<!-- Display product material -->
<p>
Material: {{ product.metafields.custom.material_composition.value }}
</p>
<!-- With fallback -->
{% if product.metafields.custom.material_composition %}
<p>{{ product.metafields.custom.material_composition.value }}</p>
{% else %}
<p>Material not specified</p>
{% endif %}
<!-- Parse JSON -->
{% assign material = product.metafields.custom.material_composition.value | parse_json %}
Cotton: {{ material.cotton }}%
Metaobjects: Structured Custom Data
What are Metaobjects?
Metaobjects are completely custom data models that you define yourself. They are like "custom tables in Shopify."
Metaobjects vs. Metafields
| Aspect | Metafields | Metaobjects |
|---|---|---|
| Attachment | Yes (to Product, Order, etc.) | No (standalone) |
| Structure | Key-value pairs | Complete data model |
| Relationships | Limited | Yes, bidirectional |
| Complexity | Simple data | Complex data |
| Queries | Per entity | GraphQL like standard entities |
Metaobject Example: Product Bundle
# Metaobject Definition (via Admin)
# Type: "product_bundle"
# Fields:
# - bundle_name (single_line_text)
# - bundle_description (multi_line_text)
# - bundle_price (number_decimal)
# - products (list.product_reference)
# - discount_percentage (number_integer)
Query in GraphQL:
query GetBundle($id: ID!) {
metaobject(id: $id) {
id
handle
fields {
key
value
}
}
}
Result:
{
"metaobject": {
"id": "gid://shopify/Metaobject/abc123",
"handle": "summer-bundle",
"fields": [
{
"key": "bundle_name",
"value": "Summer Bundle"
},
{
"key": "bundle_price",
"value": "99.99"
},
{
"key": "products",
"value": ["gid://shopify/Product/123", "gid://shopify/Product/456"]
}
]
}
}
Relationships: Connecting Metaobjects
New in 2024: Metaobjects can reference each other.
Example: Brand <-> Product
Metaobject "brand":
Fields:
- brand_name (text)
- brand_logo (file_reference)
- brand_description (text)
- featured_products (list.product_reference) <- Relationship!
In Liquid:
{% if product.metafields.custom.brand %}
{% assign brand = product.metafields.custom.brand.value %}
<p>Brand: {{ brand.brand_name }}</p>
<img src="{{ brand.brand_logo.url }}" alt="">
{% endif %}
Bidirectional Relationships
query {
products(first: 5) {
edges {
node {
id
title
# Back to Brand (if defined)
brand: metafield(namespace: "custom", key: "brand") {
value
}
}
}
}
# Reverse
metaobjects(type: "brand", first: 5) {
edges {
node {
id
fields {
value # These Products reference me
}
}
}
}
}
Real-World: Bekateq Case Study
Bekateq needed a complex data model:
- 4,400+ lines of code
- 24 different collection templates
- Custom attributes for each product type
- Zero external dependencies
Their solution: Metaobjects + Metafields
The Data Model
Product
|-- Metafields
| |-- technical_specs (json)
| |-- available_colors (list.text)
| +-- warranty_period (number_integer)
+-- Relationships via Metafield
+-- Documentation Metaobject
|-- manual_pdf
|-- installation_guide
+-- faq_entries
Brand (Metaobject)
|-- brand_name
|-- brand_logo
|-- products (list.product_reference)
+-- certifications (list.text)
Category (Metaobject)
|-- category_name
|-- parent_category (category_reference)
+-- featured_products (list.product_reference)
Why this works: Bekateq didn't need 5 custom apps. They needed a clean data model.
Metafields or Metaobjects? Decision Matrix
| Scenario | Solution |
|---|---|
| Additional product info (e.g., material) | Metafield |
| Many additional fields on product | Metaobject (then reference) |
| Separate "table" with its own entity | Metaobject |
| Relationship between products | Metaobject with relationships |
| Extending customer data | Metafield on Customer |
| Complex business logic data | External DB + Custom App |
GraphQL for Custom Data Queries
Querying Metafields
query GetProductsWithMaterial {
products(first: 10, query: "has_metafield:custom.material_composition") {
edges {
node {
id
title
metafields(namespace: "custom", first: 10) {
edges {
node {
key
value
type
}
}
}
}
}
}
}
Metaobjects with Filtering
query GetBundles {
metaobjects(type: "product_bundle", first: 10) {
edges {
node {
id
fields {
key
value
}
}
}
}
}
Complex Queries: Product + Metaobject + Relationships
query {
product(id: "gid://shopify/Product/123") {
id
title
variants(first: 5) {
edges {
node {
id
price
sku
}
}
}
# Metafield reference to Brand
brand: metafield(namespace: "custom", key: "brand") {
reference {
... on Metaobject {
id
fields {
key
value
}
# Back-reference
# products (would work if bidirectionally defined)
}
}
}
}
}
Practical Architecture Decisions
Scenario 1: Product Bundles
Option A: Metafield on Product
{
"key": "bundle_products",
"value": "[\"gid://shopify/Product/1\", \"gid://shopify/Product/2\"]",
"type": "list.product_reference"
}
Problem: If the bundle price changes, you have to update all linked products.
Option B: Metaobject "Bundle"
Bundle (Metaobject)
|-- bundle_name
|-- bundle_price
|-- bundled_products (list.product_reference)
+-- discount_amount
Advantage: Bundle exists independently. Clear responsibility.
Our recommendation: Option B (Metaobject)
Scenario 2: Technical Specifications
Option A: Many Individual Metafields
- power_consumption
- operating_temperature
- noise_level
- warranty_months
Problem: For every new spec, you need a new metafield. Inflexible.
Option B: One JSON Metafield
{
"key": "technical_specs",
"value": "{\"power\": 100, \"temp_min\": -20, \"temp_max\": 50, \"noise\": 65}",
"type": "json"
}
Advantage: Flexible. New specs without schema changes.
Our recommendation: Option B (JSON Metafield)
Limits and Workarounds
Limit 1: No Access to External Data Sources
Problem: You need Shopify data + ERP data combined in GraphQL.
Solution: Custom app as middleware
// Your Custom App
app.post('/api/product-with-erp', async (req, res) => {
const { productId } = req.body;
// 1. Shopify Data
const shopifyProduct = await shopify.graphql(`
query { product(id: "${productId}") { ... } }
`);
// 2. ERP Data
const erpData = await erp.getProduct(shopifyProduct.sku);
// 3. Combine
res.json({
...shopifyProduct,
erpInventory: erpData.inventory,
erpCost: erpData.cost
});
});
Limit 2: Performance with Many Metafields
Problem: A product with 50 metafields = slow to load
Solution: Strategically use metaobjects
// Instead of:
// Product {
// metafield_1, metafield_2, ..., metafield_50
// }
// Better:
Product {
technical_specs: Metaobject, // 30 specs bundled
marketing_data: Metaobject, // 15 marketing fields
inventory_settings: Metaobject // 5 inventory fields
}
Limit 3: No Transactions Across Entities
Problem: You need atomic updates across Product + Metaobject.
Solution: Custom app with webhook handling
// When webhook "order.create" arrives
// 1. Update Shopify Product Inventory
// 2. Update Custom Metaobject "sales_stats"
// With retry logic if one fails
try {
await updateProductInventory(orderId);
await updateSalesStats(orderId);
} catch (error) {
// Retry or rollback
await logError(orderId, error);
}
Best Practices
1. Namespace Convention
<!-- CORRECT: Namespace for organization -->
product.metafields.custom.material_composition
product.metafields.marketing.hero_text
product.metafields.technical.power_rating
<!-- WRONG: Everything in one -->
product.metafields.custom.everything
2. Set Types Correctly
// CORRECT
{
"key": "quantity",
"value": "100",
"type": "number_integer"
}
// WRONG
{
"key": "quantity",
"value": "\"100\"", // String instead of integer
"type": "number_integer"
}
3. Relationships Instead of Complex JSONs
// WRONG: Tightly coupled
{
"key": "related_products",
"value": "[{\"id\": \"123\", \"name\": \"Product\"}, ...]",
"type": "json"
}
// CORRECT: Loose coupling
{
"key": "related_products",
"value": "[\"gid://shopify/Product/123\", ...]",
"type": "list.product_reference"
}
4. Versioning for Changes
// When you change metafield structure
// v1: { material: "cotton" }
// v2: { material: "cotton", origin_country: "India" }
// Version it!
product.metafields.custom.material_data_v2
// Old clients use v1, new ones v2
Checklist: Data Model Design
- Have I used the standard fields (Products, Collections, etc.)?
- Are metafields the right choice for my custom data?
- Should I use metaobjects instead?
- Are relationships defined?
- Have I chosen types correctly?
- Are there limits I'm exceeding?
- Do I need external data (then: Custom App)?
- Are namespace conventions consistent?
- Have I considered performance implications?
About the Author
Claudio Gerlich is Technical Architect at smplx. and has been specialized in Shopify data modeling since 2020. With Bekateq, we elegantly solved a complex data model with metaobjects -- zero external dependencies, pure Shopify.
Data modeling is the foundation. Good models = simple features. Bad models = chaos.
smplx. is a Shopify Technical Partner (since 2020) based in Munsterland, NRW.