Корзина в Django: где хранить и как не потерять товары
Корзина в сессии живёт до логина, корзина в базе переживает вход и меняет пользователя — но требует чистки. Разбираем четыре подхода к хранению корзины в Django 5.x с их ценой.

Пользователь набрал товаров без входа, потом залогинился — и корзина опустела. Или наоборот: залогинился с двух устройств, а на одном лежит вчерашнее. Оба сценария упираются в один вопрос: где физически хранить корзину. Хранилище вы выбираете один раз в начале проекта, а платите за выбор каждый раз при логине, при аналитике брошенных корзин и при чистке базы. Django из коробки даёт сессии и ORM — этого достаточно для всех вариантов, но каждый ведёт себя по-своему.
Материал для Django 5.x. Разбираем четыре подхода к хранению: сессия, модель в базе, гибрид «аноним в сессии — вошедший в базе» и внешний кеш. Логику скидок, резервирование остатков и оформление заказа не трогаем — это отдельные темы.
Четыре способа хранить корзину
1. Только сессия
Корзина — словарь в request.session. По умолчанию Django кладёт сессию в таблицу django_session (бэкенд django.contrib.sessions.backends.db), а в куке хранит только ключ сессии.
# cart/cart.py — Django 5.x
from decimal import Decimal
from shop.models import Product
CART_SESSION_KEY = "cart"
class Cart:
def __init__(self, request):
self.session = request.session
cart = self.session.get(CART_SESSION_KEY)
if cart is None:
cart = self.session[CART_SESSION_KEY] = {}
self.cart = cart
def add(self, product, quantity=1):
product_id = str(product.id)
if product_id not in self.cart:
self.cart[product_id] = {
"quantity": 0,
"price": str(product.price), # Decimal не сериализуется в JSON
}
self.cart[product_id]["quantity"] += quantity
self.save()
def save(self):
# помечаем сессию как изменённую, иначе Django не запишет вложенный dict
self.session.modified = True
def __iter__(self):
product_ids = self.cart.keys()
products = Product.objects.filter(id__in=product_ids)
cart = self.cart.copy()
for product in products:
item = cart[str(product.id)]
item["product"] = product
item["price"] = Decimal(item["price"])
item["total"] = item["price"] * item["quantity"]
yield item
def __len__(self):
return sum(item["quantity"] for item in self.cart.values())
Две ловушки, на которые новички наступают одинаково. Первая: сессия по умолчанию сериализуется в JSON (SESSION_SERIALIZER), а Decimal в JSON не влезает — цену храните строкой и приводите обратно при чтении. Вторая: Django не отслеживает изменения внутри вложенного словаря. Если вы поменяли self.cart[id]["quantity"], но не выставили self.session.modified = True, запись не сохранится.
Цена подхода: корзина привязана к сессии, а не к пользователю. Логин — это cycle_key() под капотом, но словарь в сессии переживёт вход, если вы не вызываете session.flush(). Хуже другое: с телефона и с ноутбука будут две разные корзины, а аналитику брошенных корзин по такой схеме не собрать — данные лежат в django_session вперемешку и протухают вместе с сессией.
2. Модель в базе
Корзина — две таблицы: Cart с привязкой к пользователю и CartItem со ссылкой на товар и количеством.
# cart/models.py — Django 5.x
from django.conf import settings
from django.db import models
class Cart(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="cart",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class CartItem(models.Model):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey("shop.Product", on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
class Meta:
# один товар — одна строка в корзине
constraints = [
models.UniqueConstraint(
fields=["cart", "product"], name="unique_cart_product"
)
]
Здесь корзина переживает всё: логаут, смену устройства, редизайн фронта. Считать общую сумму лучше в базе, а не в Python, чтобы не тянуть все строки:
from django.db.models import F, Sum, DecimalField
total = cart.items.aggregate(
total=Sum(
F("quantity") * F("product__price"),
output_field=DecimalField(),
)
)["total"]
Обратите внимание: цена берётся из product__price текущим значением. Если вы хотите зафиксировать цену на момент добавления (как на маркетплейсах — товар подорожал, а в корзине старая цена), храните price прямо в CartItem. Это отдельное продуктовое решение, а не мелочь реализации.
Цена подхода: анонимный пользователь остаётся без корзины — OneToOneField на user его не пускает. И база копит мусор: заброшенные корзины никто не удаляет, таблица CartItem растёт. Нужна периодическая чистка по updated_at.
3. Гибрид: аноним в сессии, вошедший в базе
Самый частый вариант для реального магазина. Пока пользователь не вошёл — корзина в сессии. При логине содержимое сессии переносится в базу, и дальше работает база. Ключевой момент — слияние при входе.
Слияние корзин — это не «взять одну и выбросить другую». Если в базе уже лежит товар из прошлой сессии и тот же товар в текущей анонимной корзине, количества нужно сложить, а не перезаписать. Иначе пользователь потеряет то, что добавил вчера.
Перенос вешают на сигнал user_logged_in:
# cart/signals.py — Django 5.x
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from django.db import transaction
from .models import Cart, CartItem
from .cart import CART_SESSION_KEY
@receiver(user_logged_in)
def merge_session_cart(sender, request, user, **kwargs):
session_cart = request.session.get(CART_SESSION_KEY, {})
if not session_cart:
return
with transaction.atomic():
cart, _ = Cart.objects.get_or_create(user=user)
for product_id, item in session_cart.items():
db_item, created = CartItem.objects.get_or_create(
cart=cart,
product_id=int(product_id),
defaults={"quantity": item["quantity"]},
)
if not created:
# товар уже был — складываем количества
db_item.quantity += item["quantity"]
db_item.save(update_fields=["quantity"])
# чистим сессионную корзину, чтобы не слить дважды
request.session.pop(CART_SESSION_KEY, None)
request.session.modified = True
Оберните перенос в transaction.atomic() — если вход упадёт на середине слияния, вы не хотите получить корзину, где половина товаров перенесена, а половина потеряна. И обязательно очищайте сессионную корзину после переноса: без этого повторный логин в той же сессии удвоит количества.
4. Внешний кеш (Redis)
Корзина в Redis через django.core.cache. Django 5.x умеет Redis без сторонних пакетов — бэкенд django.core.cache.backends.redis.RedisCache. Подход берут, когда корзина живёт коротко, но операций с ней много: добавления, пересчёты, проверки остатков на каждый клик.
# settings.py — Django 5.x, встроенный Redis-бэкенд
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379",
}
}
Плюс — скорость и автоматический TTL: заброшенная корзина сама протухнет через заданное время, чистить базу не нужно. Минус — Redis по умолчанию не гарантирует сохранность: перезапуск без persistence обнулит корзины. Для корзины это часто приемлемо, для заказа — нет.
Что выбирать
| Подход | Аноним | Переживает логин | Между устройствами | Чистка | Когда брать |
|---|---|---|---|---|---|
| Сессия | да | да, если не flush | нет | вместе с сессией | простой магазин, лендинг |
| Модель в БД | нет | да | да | вручную по updated_at | только для вошедших |
| Гибрид | да | да, со слиянием | да, после входа | сессия + БД | боевой e-commerce |
| Redis | да | зависит от ключа | да, если ключ по user | авто по TTL | высокая частота операций |
- Нет регистрации или она необязательна, товаров немного — сессия.
- Корзина только для вошедших (B2B-кабинет, подписки) — модель в БД.
- Обычный магазин, где покупают и без входа, и с входом — гибрид.
- Тысячи операций с корзиной в минуту, корзина недолгоживущая — Redis.
Что ломается на практике
- Decimal в JSON-сессии.
TypeError: Object of type Decimal is not JSON serializableпри сохранении. Храните цену строкой, приводите кDecimalпри чтении. - Молчаливая потеря изменений в сессии. Поменяли вложенный словарь, забыли
session.modified = True— данные не записались, ошибки нет. - Товар удалён, а в корзине остался. При итерации фильтруйте
Product.objects.filter(id__in=...)и молча пропускайте недостающие, иначеKeyErrorна несуществующем товаре. - Двойное слияние. Не очистили сессионную корзину после переноса — повторный логин удваивает количества.
- Гонка при слиянии. Без
transaction.atomic()параллельные запросы при входе создают дублиCartItem.UniqueConstraintна пареcart + productловит это на уровне базы.
Что настроить дальше
После выбора хранилища сделайте три вещи. Первая — задайте политику протухания: для сессий это SESSION_COOKIE_AGE, для БД — периодическая задача, удаляющая корзины старше N дней по updated_at. Вторая — решите вопрос цены: фиксировать её при добавлении или брать актуальную. Третья — при переходе к оформлению копируйте состав корзины в заказ отдельными строками, а не ссылайтесь на CartItem: корзину пользователь ещё изменит, а заказ должен остаться неизменным. Резервирование остатков и защита от гонки на этапе оформления — отдельная тема.
Карточки для маркетплейсов: Таблица товаров и папка фотографий на входе — комплект слайдов и описаний на выходе, по требованиям Wildberries и Ozon.
Спросить за 5 ₽Источники: Django sessions — официальная документация, Django cache framework (Redis backend)
Частые вопросы
У меня Django 4.2, встроенный Redis-бэкенд заработает?
Почему изменения корзины в сессии не сохраняются?
Как не потерять анонимную корзину при входе?
Стоит ли хранить цену прямо в CartItem?
Как чистить заброшенные корзины в базе?
Что будет, если товар удалили, а он лежит в корзине?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.