smplx.
Shopify Architecture

Data Modeling in Shopify: Metafields, Metaobjects, and Custom Data [2026]

Claudio Gerlich··12 min

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.