A CSV file can be readable and still contain bad inventory data. This small validator checks one explicit schema before an import: sku,quantity,unit_price. It only reads the input. It does not repair records, update stock or calculate totals.
Run the sample
Use Python 3 from this folder; no packages need installing:
python validate_csv.py valid_inventory.csv
Actual output:
VALID: inventory passed all checks
The successful exit code is 0. Now run:
python validate_csv.py invalid_inventory.csv
Actual output:
INVALID: 8 problem(s)
- line 2: quantity must be a positive integer (ASCII digits)
- line 2: unit_price must be finite, nonnegative and have at most 2 fractional digits
- line 3: duplicate SKU 'BOLT-8' (case-sensitive)
- line 3: unit_price must be finite, nonnegative and have at most 2 fractional digits
- line 4: quantity must be a positive integer (ASCII digits)
- line 4: unit_price must be finite, nonnegative and have at most 2 fractional digits
- line 5: expected 3 fields, got 2 (missing 1)
- line 6: expected 3 fields, got 4 (extra 1)
The invalid file exits with code 1. Missing/unreadable files and usage errors exit with code 2. Validation errors go to standard output; file-reading errors go to standard error. The two CSV files contain invented inventory.
How the checks work
validate_csv(stream) returns a list of error messages. The command-line main() opens a UTF-8 file in read mode with newline="" so the csv module controls newline handling.
csv.reader(..., strict=True) handles quoted commas and multiline fields. The first record must be exactly sku,quantity,unit_price, in that order and case. A UTF-8 byte-order mark is not removed. Each later record must have exactly three fields; a short or long record gets a field-count error and is skipped. Blank records fail too. A header-only file passes.
A set tracks nonblank SKUs. Comparison is verbatim and case-sensitive: Bolt and bolt are different; surrounding spaces and Unicode forms are not normalized. An empty or whitespace-only SKU is invalid. A well-shaped row still reserves its SKU even if its quantity or price is invalid.
Quantities must be ASCII decimal digits with at least one nonzero digit. Leading zeros are allowed. Signs, spaces, decimals and digit separators are rejected. No integer conversion is needed, so a long quantity does not encounter Python's integer-string conversion limit.
Prices use Decimal directly, avoiding binary floating-point conversion. The code checks is_finite() before comparing with zero, so NaN and infinity do not slip through. The Decimal exponent must be at least -2: 1.23 passes, while 1.230 fails. Nothing is rounded.
This is a value-and-scale check, not a strict price-format rule. Decimal also accepts scientific notation, signed zero, surrounding whitespace, underscores and Unicode decimal digits. For example, 1e2 and -0.00 pass. A system requiring plain ASCII currency text should add an explicit input-format rule before using this sample for an import.
Checks and limits
python -m unittest -v
All 19 test methods passed locally on Python 3.14.3, Windows 11. Cases include malformed quoting, exact headers, missing/extra fields, duplicate SKUs, price boundaries and command-line exit codes. The command-line tests compare the sample files' bytes before and after validation.
Error locations use physical line numbers at the end of a record; a quoted multiline value spans more than one line. Row errors accumulate, but a bad header or malformed CSV stops further parsing. Python's strict CSV reader is not a complete RFC compliance checker; it can accept a quote inside an unquoted field. The default field-size limit applies (131072 characters here). The set of SKUs and accumulated errors use memory, and no maximum price, stock ceiling or inventory-catalog check is imposed.
Read the complete validator source
"""Read-only validation for a small inventory CSV; Python standard library only."""
import argparse
import csv
from decimal import Decimal, InvalidOperation
from pathlib import Path
import sys
def validate_csv(stream):
"""Return validation errors from a text stream without changing its contents."""
reader = csv.reader(stream, strict=True)
errors = []
seen = set()
try:
if next(reader, None) != ["sku", "quantity", "unit_price"]:
return ["line 1: expected exact header sku,quantity,unit_price"]
for row in reader:
line = reader.line_num
if len(row) != 3:
difference = "missing" if len(row) < 3 else "extra"
errors.append(
f"line {line}: expected 3 fields, got {len(row)} "
f"({difference} {abs(len(row) - 3)})"
)
continue
sku, quantity, unit_price = row
if not sku.strip():
errors.append(f"line {line}: sku must not be blank")
elif sku in seen:
errors.append(f"line {line}: duplicate SKU {sku!r} (case-sensitive)")
else:
seen.add(sku)
# Checking digits avoids a conversion limit for very long integers.
if not (quantity.isascii() and quantity.isdecimal() and quantity.strip("0")):
errors.append(f"line {line}: quantity must be a positive integer (ASCII digits)")
try:
price = Decimal(unit_price)
except InvalidOperation:
price = None
if (
price is None
or not price.is_finite()
or price < 0
or price.as_tuple().exponent < -2
):
errors.append(
f"line {line}: unit_price must be finite, nonnegative "
"and have at most 2 fractional digits"
)
except csv.Error as exc:
errors.append(f"line {reader.line_num or 1}: malformed CSV ({exc})")
return errors
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", type=Path, help="UTF-8 inventory CSV to validate")
args = parser.parse_args()
try:
with args.path.open("r", encoding="utf-8", newline="") as stream:
errors = validate_csv(stream)
except (OSError, UnicodeError) as exc:
print(f"ERROR: cannot read {args.path}: {exc}", file=sys.stderr)
return 2
if errors:
print(f"INVALID: {len(errors)} problem(s)")
for error in errors:
print(f"- {error}")
return 1
print("VALID: inventory passed all checks")
return 0
if __name__ == "__main__":
sys.exit(main())
Python references
csv: parsing and newline handling
decimal: finite values and decimal representation
unittest: running the checks