Skip to main content

Overview

Product records track unique products within your store. These differ from Variants, which track the unique variations of a product. For instance, a product that’s a T-shirt would have variants denoting its different colors and sizes. Together, Products and Variants describe what is for sale.
Note that as of version 4.6, certain product fields are translatable (read more about this in Internationalization).
Products have the following attributes:
AttributeDescriptionTranslatable
nameShort name for the productYes
descriptionThe most elegant, poetic turn of phrase for describing your product’s benefits and featuresYes
slugSEO slug based on the product name that is placed into the URL for the product. We use a library called friendly_id to generate themYes
statusThe status of the product. Can be draft, active, archived. Defaults to draft.No
available_onThe first date the product becomes available for sale online in your shopNo
discontinue_onDate when the product will become unavailable for sale online in your shopNo
deleted_atThe date the product is marked as deleted. We don’t remove products entirely, only soft deleting them using a library calledNo
meta_titleOptional title used for search engines instead of nameYes
meta_descriptionA description targeted at search engines for search engine optimization (SEO)Yes
meta_keywordsSeveral words and short phrases separated by commas, also targeted at search enginesYes
To understand how variants come to be, you must first understand option types and option values.

Option Types and Option Values

Option types denote the different options for a variant. A typical option type would be a size, with that option type’s values being something such as Small, Medium and Large. Another typical option type could be a color, such as Red, Green, or Blue. A product can be assigned many option types, but must be assigned at least one if you wish to create variants for that product
The name and presentation fields for option types are translatable as of version 4.6.

Variants

Variant records track the individual variants of a Product. Variants are of two types: master variants and normal variants. Variant records can track some individual properties regarding a variant, such as height, width, depth, and cost price. These properties are unique to each variant, and so are different from Product Properties, which apply to all variants of that product.
AttributeDescriptionExample Value
skuUnique identifier for each variant123TSHRT-M-G
barcodeBarcode code
barcodeA unique code that represents a variant, often used for scanning purposes.123456789
weightThe weight of the variant2
heightThe height of the variant150
widthThe width of the variant150
depthThe depth of the variant100
is_masterIndicates if the variant is a master variantfalse
track_inventoryIndicates if the inventory is tracked for this varianttrue
cost_priceThe cost price of the variant5.00
cost_currencyThe currency of the cost priceUSD
discontinue_onDate when the variant will become unavailable for sale2023-12-31 18:00

Master Variants

Every single product has a master variant, which tracks basic information such as a count on hand, a price and a . Whenever a product is created, a master variant for that product will be created too.
Logic behind is implemented in the after_initialize callback of the Spree::Product model in a ensure_master method.
Master variants are automatically created along with a product and exist for the sole purpose of having a consistent API when associating variants and line items. If there were no master variant, then line items would need to track a polymorphic association which would either be a product or a variant.

Regular Variants

Variants which are not the master variant are unique based on a option type and option value combinations. For instance, you may be selling a product which is a Baseball Jersey, which comes in the sizes “Small”, “Medium” and “Large”, as well as in the colors of “Red”, “Green”. For this combination of sizes and colors, you would be able to create 9 unique variants:
SKUSizeColor
SKU-S-RSmallRed
SKU-S-GSmallGreen
SKU-M-RMediumRed
SKU-M-GMediumGreen
SKU-L-RLargeRed
SKU-L-GLargeGreen

Default Variant

This all can sound complex and confusing, so we’ll simplify things for you. To get the default Variant for a product, you can call:
product.default_variant
How this works?
  • If a product has multiple Variants it will return the first non-master Variant based on their sort position set in the Admin Panel or Platform API.
  • If there are no non-master Variants it will return the Master Variant
So you can easily rely on this method to get the default Variant for a product.

Images

Images can be associated to the Product (via master variant) or to the individual Variants. Product images can be fetched via:
product.images
To get all images for product and all it’s variants, call variant_images on the product:
product.variant_images
To fetch individual variant images, call images on the variant:
product.default_variant.images
Image order is determined by the position attribute, which is an integer. By default, images are ordered from left to right.
Under the hood we use Rails Active Storage to handle image storage and manipulation. See the Images & Assets guide for more details on image processing, helpers, and best practices.

Product Properties

Product Properties are deprecated as of Spree 5.2. This feature was the default way to add custom attributes to products prior to Spree 5.2, but has been replaced by Metafields.For new projects, use Metafields instead. Product Properties will be removed in Spree 6.0.For existing projects, Properties are still supported for backward compatibility but require enabling via config.product_properties_enabled = true. See the upgrade guide for migration instructions.
Product properties track individual attributes for a product that don’t apply to all products. These are typically additional information about the item. For instance, a T-Shirt may have properties representing information about the kind of material used, as well as the type of fit the shirt is. A Property should not be confused with an OptionType, which is used when defining Variants for a product. You can retrieve the value for a property on a Product object by calling the property method on it and passing through that property’s name:
product.property("material")
=> "100% Cotton"
You can set a property on a product by calling the set_property method:
product.set_property("material", "100% cotton")
If this property doesn’t already exist, a new Property instance with this name will be created on the fly.
As of version 4.6, product property value fields are translatable.

Prices

Price objects track a price for a particular currency and variant combination. For instance, a Variant may be available for $15 (15 USD) and €7 (7 Euro). Price object contains 3 important attributes:
AttributeDescriptionExample Value
amountThe current selling price of the variant in the specified currency.99.90
compare_at_amountThe recommended retail price of the variant in the specified currency. This can be used to display crossed out prices in the storefront.129.90
currencyThe ISO code for the currency in which the amount and compare_at_amount are denominated.USD
If a product doesn’t have a price in the selected currency it won’t show up in the Storefront and Storefront API by default.
To fetch a list of currencies that given product is available in, call prices to get a list of related Price objects:
product.prices
=> [#<Spree::Price id: 2 ...]
To find a list of currencies that Variant is available in, call prices to get a list of related Price objects:
product.default_variant.prices
=> [#<Spree::Price id: 2 ...]
To find Product price in a selected currency via ISO symbol:
product.price_in('EUR')
=> #<Spree::Price id: 232, variant_id: 232, amount: 0.8499e2, currency: "EUR", deleted_at: nil, created_at: "2021-08-16 19:41:55.888522000 +0000", updated_at: "2021-08-16 19:41:55.888522000 +0000", compare_at_amount: nil, preferences: nil>
If there’s no price set for requested currency this will return a new Price object with the currency set to the requested currency. It will not be a persisted object.
To find Variant’s price in a selected currency:
product.default_variant.price_in('EUR')
=> #<Spree::Price id: 232, variant_id: 232, amount: 0.8499e2, currency: "EUR", deleted_at: nil, created_at: "2021-08-16 19:41:55.888522000 +0000", updated_at: "2021-08-16 19:41:55.888522000 +0000", compare_at_amount: nil, preferences: nil>
There are also other helpful methods available such as:
product.default_variant.amount_in('EUR')
 => 0.8499e2
product.default_variant.amount_in('EUR').to_s
 => "84.99"
product.price_in('EUR').display_amount.to_s
 => "€84.99"
Spree behind the scenes uses Ruby Money gem with some additional tweaks.

Price Lists

Price Lists are available as of Spree 5.2.
Price Lists allow you to create different pricing strategies for your products based on various conditions such as customer zones, specific users, or order quantities. This enables advanced pricing scenarios like:
  • Regional pricing - Different prices for different geographic regions
  • Wholesale/B2B pricing - Special prices for business customers
  • Volume discounts - Tiered pricing based on quantity purchased
  • Promotional pricing - Time-limited special offers
  • VIP customer pricing - Exclusive prices for specific users

How Price Lists Work

Each Price List contains a collection of prices for specific variants and currencies. When a customer views a product or adds it to their cart, Spree’s pricing resolver determines which price to use based on:
  1. Price List priority - Price Lists are ordered by position; higher priority lists are checked first
  2. Status - Only active or scheduled Price Lists are considered
  3. Date range - The current time must fall within starts_at and ends_at (if set)
  4. Price Rules - All configured rules must match (or any, depending on match_policy)
If no applicable Price List is found, the base price (price without a Price List) is used.

Price List Attributes

AttributeDescription
nameHuman-readable name for the Price List
statusCurrent status: draft, active, scheduled, or inactive
starts_atOptional start date/time when the Price List becomes applicable
ends_atOptional end date/time when the Price List stops being applicable
match_policyHow rules are evaluated: all (every rule must match) or any (at least one rule must match)
positionPriority order for evaluation (lower numbers = higher priority)

Price Rules

Price Rules define conditions that must be met for a Price List to apply. Spree includes three built-in rule types:
Applies the Price List based on the customer’s tax/shipping zone. Useful for regional or country-specific pricing.
# Example: Create a Price List for EU customers
price_list = Spree::PriceList.create!(
  name: 'EU Pricing',
  store: current_store,
  status: 'active',
  match_policy: 'all'
)

eu_zone = Spree::Zone.find_by!(name: 'EU')
price_list.price_rules.create!(
  type: 'Spree::PriceRules::ZoneRule',
  preferences: { zone_ids: [eu_zone.id] }
)
Applies the Price List to specific users. Useful for VIP customers, wholesale accounts, or B2B pricing.
# Example: Create a VIP Price List
price_list = Spree::PriceList.create!(
  name: 'VIP Customers',
  store: current_store,
  status: 'active',
  match_policy: 'all'
)

# Find users tagged with 'VIP'
vip_user_ids = Spree.user_class.tagged_with('VIP').pluck(:id)

price_list.price_rules.create!(
  type: 'Spree::PriceRules::UserRule',
  preferences: { user_ids: vip_user_ids }
)
Applies the Price List based on quantity purchased. Useful for bulk discounts or tiered pricing.
# Example: Create volume-based pricing tiers
# Tier 1: 10-49 units
tier1 = Spree::PriceList.create!(
  name: 'Bulk Tier 1 (10-49)',
  store: current_store,
  status: 'active',
  position: 2
)
tier1.price_rules.create!(
  type: 'Spree::PriceRules::VolumeRule',
  preferences: { min_quantity: 10, max_quantity: 49 }
)

# Tier 2: 50+ units (higher priority)
tier2 = Spree::PriceList.create!(
  name: 'Bulk Tier 2 (50+)',
  store: current_store,
  status: 'active',
  position: 1
)
tier2.price_rules.create!(
  type: 'Spree::PriceRules::VolumeRule',
  preferences: { min_quantity: 50, max_quantity: nil }
)

Creating Custom Price Rules

You can create custom Price Rules by subclassing Spree::PriceRule:
# app/models/spree/price_rules/membership_rule.rb
module Spree
  module PriceRules
    class MembershipRule < Spree::PriceRule
      preference :membership_levels, :array, default: []

      def applicable?(context)
        return false unless context.user
        return true if preferred_membership_levels.empty?

        preferred_membership_levels.include?(context.user.membership_level)
      end

      def self.description
        'Apply pricing based on membership level'
      end
    end
  end
end

Pricing Context

The Spree::Pricing::Context object carries all the information needed to resolve the correct price. It encapsulates the current pricing scenario and is used by the Spree::Pricing::Resolver to find the best applicable price.
Context Attributes
AttributeTypeRequiredDefaultDescription
variantSpree::VariantYes-The variant for which to resolve the price
currencyStringYes-ISO 4217 currency code (e.g., 'USD', 'EUR')
storeSpree::StoreNoSpree::Current.storeThe store context; Price Lists are scoped to stores
zoneSpree::ZoneNoSpree::Current.zoneThe tax/shipping zone for zone-based pricing rules (see below). Falls back to Spree::Zone.default_tax if not set.
userUser classNonilThe current user; used by User Rules for customer-specific pricing
quantityIntegerNonilThe quantity being purchased; used by Volume Rules for tiered pricing
dateTimeNoTime.currentThe date/time for price resolution; used to check Price List date ranges
orderSpree::OrderNonilThe current order; provides additional context for price resolution
Understanding the Zone Attribute
The zone attribute represents the geographic zone used for zone-based pricing. Zones in Spree can be based on countries or states and are typically used for tax and shipping calculations. Where zones come from:
  • During checkout, the zone is determined by the customer’s shipping or billing address
  • The order.tax_zone method returns the applicable tax zone based on the order’s addresses
  • Spree::Current.zone holds the current request’s zone (set automatically by controllers)
  • Falls back to Spree::Zone.default_tax if no zone is explicitly set
How zone is resolved:
# Spree::Current.zone is automatically set in controllers from the current order:
Spree::Current.zone  # => order.tax_zone || Spree::Zone.default_tax

# When creating a context, zone defaults to Spree::Current.zone
context = Spree::Pricing::Context.new(variant: variant, currency: 'USD')
context.zone  # => Spree::Current.zone
Creating a Context
There are several ways to create a pricing context:
For basic price lookups when you only need currency-based pricing:
context = Spree::Pricing::Context.from_currency(variant, 'USD')

# This is equivalent to:
context = Spree::Pricing::Context.new(
  variant: variant,
  currency: 'USD'
)
For full control over all pricing parameters:
context = Spree::Pricing::Context.new(
  variant: variant,
  currency: 'EUR',
  store: Spree::Store.find_by!(code: 'eu-store'),
  zone: Spree::Zone.find_by!(name: 'EU'),
  user: current_user,
  quantity: 10,
  date: 1.week.from_now  # Preview future pricing
)
Resolving the Price
Once you have a context, use the Spree::Pricing::Resolver to find the best price:
resolver = Spree::Pricing::Resolver.new(context)
price = resolver.resolve  # Returns a Spree::Price object

# The resolver:
# 1. Finds all applicable Price Lists (active/scheduled, within date range)
# 2. Filters by Price Rules (zone, user, quantity, etc.)
# 3. Returns the first matching price (by Price List position)
# 4. Falls back to the base price if no Price List applies
Price resolution results are cached for 15 minutes using the context’s cache key. The cache key includes all context attributes, so different scenarios are cached separately.

Getting Prices: price_for vs price_in

Spree provides two methods for fetching variant prices. Understanding when to use each is important for correct pricing behavior.
price_for (Recommended)
The price_for method resolves prices using the full Price List system. It considers all applicable Price Lists, rules, and returns the best matching price. This is the recommended method for most use cases.
# Using a Pricing Context object
context = Spree::Pricing::Context.from_order(variant, order)
price = variant.price_for(context)

# Using an options hash (context is built automatically)
price = variant.price_for(
  currency: 'USD',
  store: Spree::Current.store,
  zone: order.tax_zone,
  user: current_user,
  quantity: 5
)

# Display the resolved price
price.display_amount.to_s  # => "$89.99"
Use price_for when:
  • Displaying prices to customers in the storefront
  • Calculating line item prices during checkout
  • You need zone-based, user-based, or volume-based pricing
  • You want prices from active Price Lists to apply
price_in (Base Prices Only)
The price_in method returns only the base price (prices without a Price List) for a given currency. It bypasses the Price List system entirely.
# Get the base price in USD
base_price = variant.price_in('USD')
base_price.amount  # => 99.99
Use price_in when:
  • You specifically need the base price, ignoring all Price Lists
  • Managing prices in the admin interface
  • Importing/exporting base pricing data
  • Backward compatibility with pre-5.2 code
price_in is maintained for backward compatibility. For customer-facing pricing, always use price_for to ensure Price Lists are applied correctly.
Comparison
MethodPrice ListsRules AppliedUse Case
price_forYesYesStorefront, checkout, customer-facing
price_inNoNoAdmin, base price management
# Example: Same variant, different results
variant = Spree::Variant.find(1)

# Base price (no Price Lists)
variant.price_in('USD').amount  # => 100.00

# Resolved price (with VIP Price List applied)
context = Spree::Pricing::Context.new(
  variant: variant,
  currency: 'USD',
  user: vip_customer
)
variant.price_for(context).amount  # => 80.00 (20% VIP discount)

Managing Price List Products

Products can be added to a Price List, which creates placeholder prices for all variants:
price_list = Spree::PriceList.find(1)

# Add products to the Price List
price_list.add_products([product1.id, product2.id])

# Update prices for the Price List
price_list.prices.each do |price|
  price.update(amount: calculate_special_price(price.variant))
end

# Remove products from the Price List
price_list.remove_products([product1.id])

Time-Based Pricing

Price Lists support scheduling through starts_at and ends_at attributes:
# Create a Black Friday sale
black_friday = Spree::PriceList.create!(
  name: 'Black Friday 2025',
  store: current_store,
  status: 'scheduled',
  starts_at: Time.zone.parse('2025-11-28 00:00'),
  ends_at: Time.zone.parse('2025-11-28 23:59'),
  position: 1  # Highest priority
)
Scheduled Price Lists automatically become applicable when the current time falls within their date range. They don’t need to be manually activated.

Prototypes

A prototype is a useful way to share common OptionType and Property combinations amongst many different products. They work as a product template to speed up the creation of new products. For instance, if you’re creating a lot of clothing products, you may wish to maintain the “Size” and “Color” option types, as well as a “Fitting Type” property.

Taxons and Taxonomies

Taxonomies provide a simple, yet robust way of categorizing products by enabling store administrators to define as many separate structures as needed. When working with Taxonomies there are two key terms to understand:
  • Taxonomy – a hierarchical list which is made up of individual Taxons. Each taxonomy relates to one Taxon, which is its root node.
  • Taxon – a single child node which exists at a given point within a Taxonomy. Each Taxon can contain many (or no) sub / child taxons. Store administrators can define as many Taxonomies as required, and link a product to multiple Taxons from each Taxonomy.
By default, both Taxons and Taxonomies are ordered by their position attribute.
Taxons use the Nested set model for their hierarchy. The lft and rgt columns in the spree_taxons table represent the locations within the hierarchy of the item. This logic is handled by the awesome nested set gem.
Taxons link to products through an intermediary model called Classification. This model exists so that when a product is deleted, all of the links from that product to its taxons are deleted automatically. A similar action takes place when a taxon is deleted; all of the links to products are deleted automatically.
Storefront uses spree.nested_taxons_path helper method to generate full taxon URLs, which will use the taxon’s permalink, eg.
taxon = Spree::Taxon.find_by(permalink: 'clothes/dresses')
spree.nested_taxons_path(taxon)
which will output /t/categories/clothes/dresses.
As of version 4.6, the taxon name and description fields are translatable.