Querying, Filtering, and Aggregating¶
All PintField lookups compare against the stored comparator value, which is always expressed in base units. This means a filter using kilograms and a filter using grams will both resolve correctly against the same column, with no manual conversion required on your part.
The examples below assume a model like this:
from django.db import models
from django_pint_field.models import DecimalPintField
class Product(models.Model):
name = models.CharField(max_length=100)
weight = DecimalPintField(
"gram",
display_decimal_places=2,
unit_choices=["gram", "kilogram", "pound"],
)
Filtering by Exact Value¶
from decimal import Decimal
from django_pint_field.units import ureg
# Explicit Decimal magnitude
Product.objects.filter(weight__exact=ureg.Quantity(Decimal("500.00"), "gram"))
# String shorthand (Pint parses the magnitude and unit)
Product.objects.filter(weight=ureg.Quantity("500.00 gram"))
The __exact lookup is the default, so weight= and weight__exact= are equivalent.
Filtering by Comparison¶
Comparison lookups work across units transparently. When you pass a Quantity in
kilograms, the field converts it to the base-unit comparator before hitting the
database. A query for weight__gt=1 kilogram and weight__gt=1000 gram produce
the same SQL.
# Greater than
Product.objects.filter(weight__gt=ureg.Quantity(Decimal("1.000"), "kilogram"))
# Greater than or equal to
Product.objects.filter(weight__gte=ureg.Quantity("1.000 kilogram"))
# Less than
Product.objects.filter(weight__lt=ureg.Quantity(Decimal("500.00"), "gram"))
# Less than or equal to
Product.objects.filter(weight__lte=ureg.Quantity("500.00 gram"))
Filtering by Range¶
Range queries accept a two-element tuple of Quantity objects. The units do not need to match each other; each boundary is independently converted to the comparator.
min_weight = ureg.Quantity(Decimal("100.00"), "gram")
max_weight = ureg.Quantity(Decimal("1.000"), "kilogram")
Product.objects.filter(weight__range=(min_weight, max_weight))
Checking for Null Values¶
If the field was defined with null=True, you can filter on presence:
# Products with no weight recorded
Product.objects.filter(weight__isnull=True)
# Products that do have a weight
Product.objects.filter(weight__isnull=False)
Combining Filters¶
Multiple keyword arguments in the same .filter() call are ANDed together.
For OR logic, use Django’s Q objects.
from django.db.models import Q
# AND: products between 250 g and 750 g
medium_products = Product.objects.filter(
weight__gt=ureg.Quantity(Decimal("250.00"), "gram"),
weight__lt=ureg.Quantity(Decimal("750.00"), "gram"),
)
# OR: products lighter than 100 g or heavier than 1 kg
mixed_products = Product.objects.filter(
Q(weight__lt=ureg.Quantity(Decimal("100.00"), "gram"))
| Q(weight__gt=ureg.Quantity(Decimal("1.000"), "kilogram"))
)
You can combine PintField lookups with lookups on regular fields in the same query:
light_named = Product.objects.filter(
name__icontains="flour",
weight__lt=ureg.Quantity(Decimal("500.00"), "gram"),
)
Using Aggregations¶
django-pint-field ships with unit-aware aggregate functions. Each one operates on
the comparator column and returns a Quantity (except PintCount, which returns an
integer).
from django_pint_field.aggregates import (
PintAvg,
PintCount,
PintMax,
PintMin,
PintStdDev,
PintSum,
PintVariance,
)
# Count non-null weights
Product.objects.aggregate(count=PintCount("weight"))
# Average weight
Product.objects.aggregate(avg=PintAvg("weight"))
# Total weight
Product.objects.aggregate(total=PintSum("weight"))
# Maximum weight
Product.objects.aggregate(max_w=PintMax("weight"))
# Minimum weight
Product.objects.aggregate(min_w=PintMin("weight"))
# Standard deviation (population by default; pass sample=True for sample std dev)
Product.objects.aggregate(std=PintStdDev("weight"))
Product.objects.aggregate(std=PintStdDev("weight", sample=True))
# Variance (population by default; pass sample=True for sample variance)
Product.objects.aggregate(var=PintVariance("weight"))
Product.objects.aggregate(var=PintVariance("weight", sample=True))
Combining Aggregates with Unit Conversion¶
You can request several aggregates at once and convert the results to whatever unit suits your display needs.
from django_pint_field.aggregates import PintAvg, PintCount, PintMax, PintMin, PintSum
class Product(models.Model):
name = models.CharField(max_length=100)
weight = DecimalPintField(
default_unit="gram",
display_decimal_places=2,
rounding_method="ROUND_HALF_UP",
)
@classmethod
def get_weight_statistics(cls):
"""Aggregate weights and convert each result to a useful unit."""
stats = cls.objects.aggregate(
total_weight=PintSum("weight"),
average_weight=PintAvg("weight"),
min_weight=PintMin("weight"),
max_weight=PintMax("weight"),
product_count=PintCount("weight"),
)
return {
"total": stats["total_weight"].quantity.to("kilogram"),
"average": stats["average_weight"].quantity.to("gram"),
"min": stats["min_weight"].quantity.to("gram"),
"max": stats["max_weight"].quantity.to("kilogram"),
"count": stats["product_count"],
}
The same pattern works when you have multiple PintFields on a model. Here, weight and volume aggregates feed into a derived density calculation:
from django_pint_field.aggregates import PintAvg, PintCount, PintSum
class Shipment(models.Model):
weight = DecimalPintField(default_unit="kilogram", display_decimal_places=2)
volume = DecimalPintField(default_unit="cubic_meter", display_decimal_places=3)
@classmethod
def get_shipping_metrics(cls):
"""Aggregate shipments and derive average density."""
metrics = cls.objects.aggregate(
total_weight=PintSum("weight"),
avg_weight=PintAvg("weight"),
total_volume=PintSum("volume"),
avg_volume=PintAvg("volume"),
shipment_count=PintCount("id"),
)
if metrics["shipment_count"] > 0:
total_weight_kg = metrics["total_weight"].quantity.to("kilogram")
total_volume_m3 = metrics["total_volume"].quantity.to("cubic_meter")
avg_density = (total_weight_kg / total_volume_m3).to("kg/m**3")
else:
avg_density = ureg.Quantity(0, "kg/m**3")
return {
**metrics,
"average_density": avg_density,
}
Advanced Aggregation Patterns¶
Parameterized output units¶
When you want the caller to control which unit the results come back in, accept the unit as a function argument and convert after aggregation:
from django_pint_field.aggregates import PintAvg, PintMax, PintMin, PintSum
class Inventory(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.IntegerField()
unit_weight = DecimalPintField(default_unit="gram", display_decimal_places=2)
@classmethod
def get_inventory_analysis(cls, output_unit="kilogram"):
"""
Aggregate inventory weights and convert results to the requested unit.
Args:
output_unit: The unit string for all weight outputs.
"""
base_stats = cls.objects.aggregate(
total_items=models.Sum("quantity"),
avg_unit_weight=PintAvg("unit_weight"),
heaviest_unit=PintMax("unit_weight"),
lightest_unit=PintMin("unit_weight"),
)
return {
"total_items": base_stats["total_items"],
"avg_unit_weight": base_stats["avg_unit_weight"].quantity.to(output_unit),
"heaviest_unit": base_stats["heaviest_unit"].quantity.to(output_unit),
"lightest_unit": base_stats["lightest_unit"].quantity.to(output_unit),
}
Choosing the output unit by magnitude¶
If the average measurement is large, display in kilograms; if small, display in grams. This avoids showing values like “0.003 kilogram” or “450000 gram”:
import logging
from django_pint_field.aggregates import (
PintAvg,
PintCount,
PintStdDev,
PintSum,
PintVariance,
)
logger = logging.getLogger(__name__)
class WeightMeasurement(models.Model):
timestamp = models.DateTimeField(auto_now_add=True)
weight = DecimalPintField(default_unit="gram", display_decimal_places=2)
@classmethod
def analyze_measurements(cls, start_date=None, end_date=None):
"""
Analyze weight measurements over an optional date window.
Automatically picks grams or kilograms for readability.
"""
queryset = cls.objects.all()
if start_date:
queryset = queryset.filter(timestamp__gte=start_date)
if end_date:
queryset = queryset.filter(timestamp__lte=end_date)
try:
stats = queryset.aggregate(
count=PintCount("weight"),
total=PintSum("weight"),
average=PintAvg("weight"),
std_dev=PintStdDev("weight"),
variance=PintVariance("weight"),
)
if stats["average"]:
avg_magnitude = stats["average"].quantity.to("gram").magnitude
if avg_magnitude > 1000:
unit = "kilogram"
else:
unit = "gram"
stats = {
"count": stats["count"],
"total": stats["total"].quantity.to(unit),
"average": stats["average"].quantity.to(unit),
"std_dev": stats["std_dev"].quantity.to(unit),
"variance": stats["variance"],
"unit": unit,
}
return stats
except Exception as e:
logger.error(f"Error analyzing measurements: {str(e)}")
return None
Unsupported Lookups¶
PintFields store composite types (comparator, magnitude, units), not simple text or
date values. The following lookups do not apply and will raise a
PintFieldLookupError if you attempt them:
contains,icontainsstartswith,istartswithendswith,iendswithinregex,iregexsearch,isearchdate,year,iso_year,month,quarter,week,week_day,iso_week_day,dayhour,minute,secondtime
Stick to the supported lookups: exact, gt, gte, lt, lte, range, and
isnull.
Best Practices¶
Index your PintFields for faster queries. Use PintFieldComparatorIndex to create
an index on the comparator component, which is what all lookups and ordering
operate against. See Production Deployment for details.
from django_pint_field.indexes import PintFieldComparatorIndex
class InventoryItem(models.Model):
weight = DecimalPintField("gram", display_decimal_places=2)
class Meta:
indexes = [
PintFieldComparatorIndex(fields=["weight"]),
]
Use iterator() for large result sets. When you need to loop over thousands of
rows and convert units on each one, iterator() avoids loading everything into memory
at once:
for item in InventoryItem.objects.all().iterator():
converted = item.weight.kilogram
print(f"Original: {item.weight}, Converted: {converted}")
Build reusable range-query helpers. Wrapping range logic in a classmethod keeps your view code clean and ensures consistent precision handling:
from decimal import Decimal
from django.core.exceptions import ValidationError
from django_pint_field.units import ureg
class Product(models.Model):
weight = DecimalPintField("gram", display_decimal_places=2)
@classmethod
def get_by_weight_range(cls, min_weight, max_weight, unit="gram", precision=2):
"""
Query products within a weight range, with explicit precision control.
"""
try:
fmt = Decimal(f"0.{'0' * precision}")
min_q = ureg.Quantity(Decimal(min_weight).quantize(fmt), unit)
max_q = ureg.Quantity(Decimal(max_weight).quantize(fmt), unit)
return cls.objects.filter(weight__gte=min_q, weight__lte=max_q)
except Exception as e:
raise ValidationError(f"Invalid weight range: {str(e)}")
@classmethod
def get_weight_distribution(cls, ranges, unit="kilogram", precision=3):
"""
Count products in each weight bucket.
Args:
ranges: list of (min, max) tuples in the given unit.
unit: the unit for the range boundaries.
precision: decimal places for quantizing boundaries.
"""
distribution = {}
for min_val, max_val in ranges:
fmt = Decimal(f"0.{'0' * precision}")
min_dec = Decimal(min_val).quantize(fmt)
max_dec = Decimal(max_val).quantize(fmt)
count = cls.get_by_weight_range(min_dec, max_dec, unit, precision).count()
distribution[f"{min_dec}-{max_dec} {unit}"] = count
return distribution
Keep units compatible in your queries. You can freely mix grams and kilograms (or any units of the same dimensionality) in lookups because the comparator handles conversion. However, passing a length Quantity to a weight field will raise an error. Validate dimensionality before querying if the unit comes from user input.
Converting and comparing in the database¶
PintConvert performs unit conversion inside PostgreSQL using the stored
base-unit comparator, so filtering and ordering never load rows into Python:
from django_pint_field import PintConvert
(
Package.objects
.annotate(kg=PintConvert("weight", "kilogram"))
.filter(kg__gte=2)
.order_by("-kg")
)
The annotation is a DecimalField holding the magnitude in the requested
unit. It works for offset units (temperature) as well as multiplicative ones.
Compare it against plain numbers (filter(kg__gte=2)), not Quantity objects -
the annotation is a number in to_unit, not a Pint field.
PintComparator("weight") and PintMagnitude("weight") expose the raw
composite components when you need them.
Use native lookups for index-backed filtering. A PintFieldComparatorIndex
indexes the bare comparator. Converting to a non-base unit wraps the comparator
in arithmetic the planner cannot match against that index, so a large
filter(kg__gte=…) on a PintConvert annotation may fall back to a sequential
scan. For the indexed hot path, filter the field directly with a Quantity,
which compares cross-unit via the comparator and uses the index:
Package.objects.filter(weight__gte=Quantity(2, "kilogram"))
Reserve PintConvert for projecting a converted magnitude into results and for
convenience filtering/ordering where an index is not critical. (Converting to the
field’s base unit collapses to the bare comparator and remains index-usable.)
Precision: the conversion uses PostgreSQL numeric division, whose scale can
differ slightly from Python/Pint. Use range or gte/lte comparisons rather than
__exact on a converted annotation.
Dimensionality: to_unit must measure the same quantity as the field’s
default_unit. Converting a mass field to a length unit (e.g.
PintConvert("weight", "meter")) raises ValidationError when the query is
built. An undefined or empty to_unit raises at expression construction time.
Analytics aggregates¶
from django_pint_field import PintPercentile, PintMedian, pint_histogram
Reading.objects.aggregate(p95=PintPercentile("value", percentile=0.95))
Reading.objects.aggregate(median=PintMedian("value"))
pint_histogram(
Reading.objects.all(), "value",
buckets=10,
min_value=Quantity(0, "kilogram"),
max_value=Quantity(100, "kilogram"),
)
Percentile and median results are PintFieldProxy objects in the field’s base
unit (pass output_unit= to convert). pint_histogram returns one dict per
bucket - {"bucket", "lower", "upper", "count"} - with Quantity boundaries
and a row count, all computed in PostgreSQL. Values below min_value or
at/above max_value fall outside the returned buckets (width_bucket
semantics).
Running totals and partitioned aggregates (window functions)¶
A plain Django Window cannot carry a Pint aggregate’s unit conversion: the
window resolves to the outer select column, so the aggregate’s conversion step
never runs and you would get the raw base-unit number wearing the wrong unit -
a silent, potentially large error. To prevent that, the unit-bearing aggregates
(PintSum, PintAvg, PintMax, PintMin, PintStdDev, PintVariance)
refuse to be wrapped in a bare Window:
from django.db.models import Window, F
from django_pint_field.aggregates import PintSum
# Raises ValueError: "Expression 'PintSum' isn't compatible with OVER clauses."
Reservoir.objects.annotate(running=Window(PintSum("capacity"), order_by=F("pk").asc()))
Use PintWindow instead. It wraps the aggregate, runs the same conversion the
non-window path uses, and returns a PintFieldProxy. Like every other
aggregate, the result is in the field’s base unit (e.g. cubic_meter for an
acre_feet field); pass output_unit= on the wrapped aggregate, or call
.quantity.to(...) on the result, to get a specific unit:
from django.db.models import F
from django_pint_field.aggregates import PintSum, PintWindow
# Running (cumulative) total, correctly unit-aware (result in the base unit):
qs = Reservoir.objects.annotate(
running=PintWindow(PintSum("capacity"), order_by=F("pk").asc())
)
for row in qs:
print(row.running.quantity.to("acre_feet")) # convert to the unit you want
# Partitioned total:
Reservoir.objects.annotate(
per_basin=PintWindow(PintSum("capacity"), partition_by=[F("basin")])
)
# Or have the running total come back already in a chosen unit:
PintWindow(PintSum("capacity", output_unit="acre_feet"), order_by=F("pk").asc())
PintWindow accepts the same partition_by, order_by, and frame arguments
as Window. For PintCount (which has no unit to convert) use a plain
Window directly. Ordered-set aggregates (PintPercentile, PintMedian)
cannot be used in an OVER clause and are rejected at construction.
Bulk and expression-based updates¶
bulk_update() works with Pint fields - assign a Quantity (in any compatible
unit) to each instance and call bulk_update; the values round-trip correctly:
for account in accounts:
account.balance = ureg.Quantity(new_magnitude, "acre_feet")
Account.objects.bulk_update(accounts, ["balance"], batch_size=500)
A conditional update() with Case/When is also supported, but a bare
Case compiles to text; wrap it in Cast(..., output_field=field) so
PostgreSQL casts it to the composite type (the same cast bulk_update applies
internally):
from django.db.models import Case, When, Value
from django.db.models.functions import Cast
field = Account._meta.get_field("balance")
Account.objects.update(
balance=Cast(
Case(
When(name="a", then=Value(ureg.Quantity(11, "acre_feet"))),
default=Value(ureg.Quantity(22, "acre_feet")),
),
output_field=field,
)
)
F() arithmetic on a Pint column is not supported. The field is a
PostgreSQL composite (comparator, magnitude, units), not a scalar, so there is
no pint_field - numeric operator. An expression like
update(balance=F("balance") - loss) raises a clear ValidationError
(“Arithmetic on a PintField is not supported …”). Fetch the rows, compute the
new Quantity in Python, and write it back (via bulk_update for many rows):
# Instead of: Account.objects.update(balance=F("balance") - loss) # unsupported
for account in Account.objects.all():
account.balance = account.balance.quantity - loss
Account.objects.bulk_update(accounts, ["balance"])
Filtering with django-filter¶
These filters require the optional django-filter
package (pip install django-filter). Import them from django_pint_field.filters:
import django_filters
from django_pint_field.filters import PintFieldFilter, PintFieldRangeFilter
class PackageFilter(django_filters.FilterSet):
weight = PintFieldRangeFilter(field_name="weight") # weight_min / weight_max
min_weight = PintFieldFilter(field_name="weight", lookup_expr="gte")
class Meta:
model = Package
fields = ["weight"]
Inputs are "<magnitude> <unit>" strings (e.g. "2 kilogram"). Comparison is
cross-unit via the base-unit comparator, so users can filter in any compatible
unit regardless of how the value was stored. Invalid input (a bad number or
undefined unit) surfaces as a form validation error rather than an exception.
See API Reference for the complete lookup and aggregate API.
See Concepts for how the comparator enables cross-unit queries.