diff --git a/stdnum/upi.py b/stdnum/upi.py
new file mode 100644
index 00000000..f333a6ff
--- /dev/null
+++ b/stdnum/upi.py
@@ -0,0 +1,84 @@
+# upi.py - functions for handling Unique Product Identifiers (UPIs)
+#
+# Copyright (C) 2026 Jon Arnfred
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, see .
+
+"""UPI (ISO 4914 Unique Product Identifier).
+
+The Unique Product Identifier (UPI) is a 12-character alphanumeric code used
+to identify over-the-counter (OTC) derivative products. It consists of the
+two-character prefix ``QZ``, nine characters that identify the product, and
+one check character. Vowels and the letter Y are not used. The check
+character uses the ISO 7064 Mod 31, 30 algorithm.
+
+More information:
+
+* https://www.iso.org/standard/80506.html
+* https://www.anna-dsb.com/upi-/
+* https://zakon.rada.gov.ua/laws/show/z1549-21
+
+>>> validate('QZK12RNSP6P6')
+'QZK12RNSP6P6'
+>>> validate('QZK12RNSP6P7')
+Traceback (most recent call last):
+ ...
+InvalidChecksum: ...
+>>> calc_check_digit('QZK12RNSP6P')
+'6'
+"""
+
+from __future__ import annotations
+
+from stdnum.exceptions import *
+from stdnum.iso7064 import mod_37_36
+from stdnum.util import clean
+
+
+# The UPI alphabet excludes vowels and the letter Y. Using this alphabet with
+# the generic Mod x+1, x implementation gives the ISO 7064 Mod 31, 30 method.
+_alphabet = '0123456789BCDFGHJKLMNPQRSTVWXZ'
+
+
+def compact(number: str) -> str:
+ """Convert the UPI to its minimal representation. This strips spaces and
+ removes surrounding whitespace."""
+ return clean(number, ' ').strip().upper()
+
+
+def calc_check_digit(number: str) -> str:
+ """Calculate the check character for the number."""
+ return mod_37_36.calc_check_digit(compact(number), alphabet=_alphabet)
+
+
+def validate(number: str) -> str:
+ """Check if the number provided is a valid UPI. This checks the length,
+ format, prefix and check character."""
+ number = compact(number)
+ if not all(x in _alphabet for x in number):
+ raise InvalidFormat()
+ if len(number) != 12:
+ raise InvalidLength()
+ if not number.startswith('QZ'):
+ raise InvalidComponent()
+ mod_37_36.validate(number, alphabet=_alphabet)
+ return number
+
+
+def is_valid(number: str) -> bool:
+ """Check if the number provided is a valid UPI."""
+ try:
+ return bool(validate(number))
+ except ValidationError:
+ return False
diff --git a/tests/test_upi.doctest b/tests/test_upi.doctest
new file mode 100644
index 00000000..f5edb573
--- /dev/null
+++ b/tests/test_upi.doctest
@@ -0,0 +1,82 @@
+test_upi.doctest - more detailed doctests for the stdnum.upi module
+
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public
+License along with this library; if not, see .
+
+
+This file contains more detailed doctests for the stdnum.upi module.
+
+>>> from stdnum import upi
+>>> from stdnum.exceptions import *
+
+
+Spaces and case are normalised by compact().
+
+>>> upi.compact(' qzk1 2rnsp6p6 ')
+'QZK12RNSP6P6'
+>>> upi.validate('qzk12rnsp6p6')
+'QZK12RNSP6P6'
+
+
+The prefix must be QZ and only digits and consonants other than Y are
+permitted.
+
+>>> upi.validate('XZK12RNSP6P6')
+Traceback (most recent call last):
+ ...
+InvalidComponent: ...
+>>> upi.validate('QZA12RNSP6P6')
+Traceback (most recent call last):
+ ...
+InvalidFormat: ...
+>>> upi.validate('QZY12RNSP6P6')
+Traceback (most recent call last):
+ ...
+InvalidFormat: ...
+>>> upi.validate('QZK12RNSP6P')
+Traceback (most recent call last):
+ ...
+InvalidLength: ...
+
+
+The final character is an ISO 7064 Mod 31, 30 check character.
+
+>>> upi.calc_check_digit('QZK12RNSP6P')
+'6'
+>>> upi.validate('QZK12RNSP6P7')
+Traceback (most recent call last):
+ ...
+InvalidChecksum: ...
+
+
+These examples have been published by the Derivatives Service Bureau, CME
+Group and in ISO 4914 supporting material.
+
+* https://www.anna-dsb.com/download/dsb-2770-isin-eq-sw-total-return-swap-basket-template-definition/
+* https://www.anna-dsb.com/download/dsb-2771-upi-eq-sw-total-return-swap-single-name-template-definition/
+* https://www.cmegroup.com/notices/reference-data-api/2024/02/20240222.html
+* https://zakon.rada.gov.ua/laws/show/z1549-21
+
+>>> numbers = '''
+...
+... QZDXL66WTF3C
+... QZK12RNSP6P6
+... QZNX2JD91QCG
+... QZVLFS6FH9VZ
+...
+... '''
+>>> [x for x in numbers.splitlines() if x and not upi.is_valid(x)]
+[]
+>>> upi.is_valid('QZK12RNSP6P7')
+False