How-to
Automating Reporting WorkflowsDeep dive

Import an Excel File into Django Models

Load a spreadsheet of products, customers or prices into a Django database the safe way: read every cell as text, validate each row with the model's own full_clean, report errors by Excel row number, reject duplicate keys in the file, create missing related objects, upsert everything in one transaction with bulk_create, and return an error workbook the uploader can fix.

Sooner or later every Django application gets the request: "can we upload the product list from Excel instead of typing it in?" The first version reads the rows and calls Product.objects.create in a loop, and it works until the spreadsheet contains a price with a comma, an empty name, a negative stock count or the same SKU twice — and then it fails half-way, leaving half the rows imported. A good import validates every row with the model's own rules before touching the database, reports problems by the row numbers the uploader sees in Excel, and writes all or nothing. This page, part of Serving Excel Files from Python Web Apps, builds that import against a real Django app.

Prerequisites

Bash
pip install django pandas openpyxl

The examples use a small Django app, catalog, with a Category model and a Product model — SKU (unique), name, category (foreign key), price (decimal) and stock (positive integer) — on SQLite. products_upload.xlsx is the uploaded file: eight rows, with a price typed as 129,00, a blank name, a negative stock figure and one SKU listed twice.

Python
import django
from django.core.management import call_command

django.setup()
call_command("migrate", run_syncdb=True, verbosity=0)
from catalog.models import Category, Product
print([f.name for f in Product._meta.get_fields()])
A safe import in five stages The uploaded workbook is read as text, each row is validated with the model's full_clean and collected as valid or invalid, duplicate keys are rejected, and only if every row is valid are related objects created and products upserted in one transaction; otherwise an error report goes back to the uploader. Read as text dtype=str Validate rows full_clean() Check keys duplicate SKUs Write atomically upsert in one transaction Report errors by Excel row

Step 1 — Read every cell as text

Let Django decide what a valid value is, not pandas. Reading with dtype=str and keep_default_na=False delivers each cell as the text the uploader typed, blanks as empty strings:

Python
import pandas as pd

df = pd.read_excel("products_upload.xlsx", dtype=str, keep_default_na=False)
df = df.apply(lambda col: col.str.strip())
df.insert(0, "excel_row", df.index + 2)          # header is row 1
print(df.to_string(index=False))

If pandas guessed the types, 129,00 would make the whole price column text anyway while the other prices became floats, and an empty cell would become NaN, which Django treats as a value rather than as "blank". The excel_row column records each record's row number in the spreadsheet — the index plus two, for the header and because Excel counts from one — so every error message can point at the row the uploader will look at.

Step 2 — Validate each row with the model's rules

Model.full_clean() runs the same validation a Django form or the admin would: field types, lengths, blank, choices, validators like PositiveIntegerField's minimum. Build an unsaved instance per row and collect the errors instead of stopping at the first:

Python
from django.core.exceptions import ValidationError

def validate_rows(frame):
    valid, errors = [], []
    for row in frame.itertuples(index=False):
        product = Product(sku=row.SKU, name=row.Name, price=row.Price, stock=row.Stock)
        try:
            product.full_clean(exclude=["category"], validate_unique=False)
            valid.append((row.excel_row, row.Category, product))
        except ValidationError as err:
            for field, messages in err.message_dict.items():
                errors.append({"row": row.excel_row, "sku": row.SKU, "field": field, "problem": "; ".join(messages)})
    return valid, errors

valid, errors = validate_rows(df)
print(len(valid), "valid rows")
for e in errors:
    print(e)

Three rows fail with Django's own messages: the price 129,00 is not a decimal number, the name on row 6 cannot be blank, and the stock on row 7 must be at least 0. exclude=["category"] skips the foreign key, which is resolved in a later step, and validate_unique=False skips the per-row database query for the SKU — uniqueness is handled for the whole file at once next. Validation that lives on the model is reused here for free; a custom clean() method on the model runs too.

Step 3 — Reject duplicate keys within the file

The unique constraint on sku protects the database, but a SKU listed twice in the upload is ambiguous — which row is right? Find those before writing and treat them as errors:

Python
dupes = df[df.duplicated("SKU", keep=False)]
for row in dupes.itertuples(index=False):
    errors.append({"row": row.excel_row, "sku": row.SKU, "field": "sku",
                   "problem": f"SKU appears more than once (rows {', '.join(map(str, dupes.loc[dupes['SKU'] == row.SKU, 'excel_row']))})"})
print(sorted({e["row"] for e in errors}))

Both occurrences of SKU-110 are reported, each naming the other's row, because the uploader has to choose between them. Guessing — "the last one wins" — silently discards data someone typed.

The rows the import rejected A table of errors by Excel row number. Row 3 and row 8 share SKU-110; row 5 has a price with a decimal comma; row 6 has a blank name; row 7 has a negative stock. A B C D 1 row sku field problem 2 3 SKU-110 sku appears more than once 3 5 SKU-210 price “129,00” must be a decimal 4 6 SKU-300 name cannot be blank 5 7 SKU-310 stock must be ≥ 0 6 8 SKU-110 sku appears more than once

Step 4 — All or nothing

If any row failed, import nothing and send the errors back; a half-imported product list is worse than none. When everything is valid, create missing categories and upsert the products in one transaction:

Python
from django.db import transaction

def import_products(valid):
    with transaction.atomic():
        names = {cat for _, cat, _ in valid}
        categories = {c.name: c for c in Category.objects.filter(name__in=names)}
        for name in names - set(categories):
            categories[name] = Category.objects.create(name=name)
        products = []
        for _, cat, product in valid:
            product.category = categories[cat]
            products.append(product)
        Product.objects.bulk_create(products, update_conflicts=True, unique_fields=["sku"],
                                    update_fields=["name", "category", "price", "stock"])
    return len(products)

if errors:
    print(f"{len({e['row'] for e in errors})} rows have problems — nothing imported")
print("products in database:", Product.objects.count())

transaction.atomic() makes the whole import one unit: if anything inside raises, every change is rolled back. bulk_create with update_conflicts=True is an upsert — insert new SKUs, update the named fields of existing ones — in a single statement on databases that support it, including PostgreSQL, SQLite and MySQL/MariaDB. Categories are looked up once for all names and created only if missing, instead of one query per row.

Step 5 — Import the corrected file

The uploader fixes the five rows — the decimal point, the name, the stock, and keeps one of the two SKU-110 rows — and uploads again. This time every row is valid and the import runs:

Python
fixed = df[df["excel_row"] != 3].copy()
fixed.loc[fixed["SKU"] == "SKU-210", "Price"] = "129.00"
fixed.loc[fixed["SKU"] == "SKU-300", "Name"] = "Brass floor lamp"
fixed.loc[fixed["SKU"] == "SKU-310", "Stock"] = "2"
valid, errors = validate_rows(fixed)
assert not errors and not fixed["SKU"].duplicated().any()
print(import_products(valid), "products imported")
print(list(Product.objects.order_by("sku").values_list("sku", "category__name", "price", "stock")))

again = fixed.assign(Stock="0")
print(import_products(validate_rows(again)[0]), "rows upserted a second time;", Product.objects.count(), "products,",
      Product.objects.filter(stock=0).count(), "now with stock 0")

Seven products are created along with the four categories. Importing the same file again with different stock figures updates the existing products instead of failing on the unique constraint or creating duplicates — the count stays at seven. That is what makes the import safe to run every week with the latest spreadsheet. In production, wrap the same functions in a management command — python manage.py import_products products.xlsx — so scheduled imports and uploads through a view share one tested code path.

The same import, first and second time Two panels. On the first run seven products and four categories are created. On the second run with changed stock figures the same seven products are updated in place and no rows are added. First upload 4 categories created 7 products inserted all in one transaction Weekly re-upload 7 products updated no duplicates, no errors count stays at 7

Step 6 — Send the errors back as a workbook

People fix spreadsheets in Excel, so return the problems in Excel: the original rows with an error column, sorted by row number:

Python
valid, errors = validate_rows(df)
dupes = df[df.duplicated("SKU", keep=False)]
errors += [{"row": r.excel_row, "sku": r.SKU, "field": "sku", "problem": "SKU appears more than once"}
           for r in dupes.itertuples(index=False)]
problems = (pd.DataFrame(errors).groupby("row")["problem"].apply("; ".join).rename("Problem"))
report = df.set_index("excel_row").join(problems, how="inner").reset_index()
report.to_excel("products_upload_errors.xlsx", index=False)
print(report[["excel_row", "SKU", "Problem"]].to_string(index=False))

In a web view this workbook is returned as a download right after the failed upload — Return an Excel File from a Flask Download Endpoint shows the response side, and the same HttpResponse pattern works in Django.

Common pitfalls

  • Creating rows one by one without a transaction. A failure half-way leaves half an import; wrap it in transaction.atomic().
  • Letting pandas guess types. Read as text and let the model validate.
  • Error messages with pandas index numbers. Report the Excel row number the uploader sees.
  • "Last one wins" for duplicate keys. Report duplicates and let a person choose.
  • Trusting uploaded files blindly. Check size and type before reading; see Validate an Uploaded Excel File in a Web App.

Conclusion

Read the upload with dtype=str and keep_default_na=False, number each record by its Excel row, validate each row with the model's full_clean() and collect every error, and reject duplicate keys inside the file. Import only when every row is valid — related objects looked up or created in bulk, products upserted with bulk_create(update_conflicts=True) inside transaction.atomic() — and otherwise return a workbook of the problems keyed by row number.

Frequently asked questions

How do I import an Excel file into a Django model? Read the file with pandas, build an unsaved model instance per row, validate it with full_clean(), and save the valid instances inside transaction.atomic(), for example with bulk_create. Report invalid rows back to the user.

How do I update existing records instead of creating duplicates? Use bulk_create with update_conflicts=True, unique_fields set to the natural key such as sku, and update_fields listing the columns to overwrite. Existing rows are updated and new ones inserted in one statement.

How do I validate Excel rows with Django's rules? Create a model instance from each row and call full_clean(). It runs field validation, blank and length checks, validators and the model's clean method, raising ValidationError with a message per field.

How do I show users which rows failed? Keep each record's spreadsheet row number — the pandas index plus two — and include it in every error. Return the failing rows with an error column as an Excel file they can fix and re-upload.

More on excel in web apps

More on excel in web apps →