Sharing something, that costed me a little wall of wail :). Still proud I’ve done it.
All code samples were anonimised, might contain errors and look terrible. If you don’t like it, just don’t read this article :).
Being anchored to a specific Python version is a common reality in enterprise environments. Mine definitely is. Python 3.8 reached its End of Life in late 2023, meaning the surrounding ecosystem of data libraries has steadily moved on.
If you are tasked with building an Excel uploader in Python 3.8, you cannot simply run pip install openpyxl and expect it to work flawlessly in a deployment pipeline. The modern versions of these libraries will either fail to install or break your environment.
Here is how to safely handle Excel uploads, parse .xlsx and legacy .xls files, and secure your endpoints-all while staying strictly within the confines of Python 3.8.
1. Navigating the Dependency Minefield
The most critical step in working with Python 3.8 today is version pinning. Modern package managers might attempt to pull libraries that require Python 3.9+, resulting in broken builds.
To process Excel files effectively, you need a combination of engines to parse the files. Here are the maximum versions you should pin in your requirements.txt:
openpyxl==3.1.5: This library handles modern.xlsxfiles and maintains compatibility with Python 3.8.xlrd==2.0.1: If you must support legacy.xls(Excel 97-2003) files, use this library. Note thatxlrdversions 2.0 and above explicitly removed support for.xlsxdue to security vulnerabilities, meaning you must useopenpyxlalongside it.defusedxml==0.7.1: Modern.xlsxfiles are essentially zipped XML files. This library prevents malicious XML payloads from crashing your server.
2. The API Gateway Trap and the Pre-Signed URL Escape Hatch
If you are deploying this Python 3.8 backend in a serverless environment-like AWS Lambda behind an API Gateway-you are going to hit a brick wall very quickly. API Gateway has a hard payload limit of 10MB. The moment someone in Accounting tries to upload their 12MB Master_Q3_Final_v4.xlsx file, API Gateway will outright reject it with a 413 Payload Too Large error. Your Python code will never even execute, and you will be left digging through CloudWatch logs wondering what happened.
The solution is to stop piping binary files through your API endpoints. Instead, use the S3 Pre-Signed URL pattern.
Your frontend asks your Python backend for a temporary, secure URL. The browser then uploads the Excel file directly to an S3 bucket. Once uploaded, the frontend tells your backend, “Hey, the file is in S3 now, go read it.”
Here is how you could fetch and process that file directly into memory using boto3 (which, thankfully, still fully supports Python 3.8) and io.BytesIO:
Python
import ioimport boto3import pandas as pd# boto3 is fully compatible with Python 3.8s3_client = boto3.client('s3')def process_s3_excel(bucket_name: str, object_key: str, filename: str) -> dict: """ Fetches an uploaded Excel file directly from S3 into memory and parses it. """ # 1. Determine the correct engine based on the extension if filename.endswith('.xlsx'): engine = 'openpyxl' elif filename.endswith('.xls'): engine = 'xlrd' else: raise ValueError("Unsupported file format.") try: # 2. Fetch the object from S3 directly into memory response = s3_client.get_object(Bucket=bucket_name, Key=object_key) file_bytes = response['Body'].read() # 3. Load the bytes into an in-memory buffer file_buffer = io.BytesIO(file_bytes) # 4. Process it # In whichever lib you want except Exception as e: raise RuntimeError(f"An error occurred reading from S3: {str(e)}")
A Nice Little CORS Configuration (Because Browsers Are Paranoid)
There is a catch to letting the frontend upload directly to S3: CORS (Cross-Origin Resource Sharing). The browser will violently block the PUT request to the S3 pre-signed URL unless the S3 bucket explicitly trusts your frontend application.
Do not try to debug this via trial and error. Just go to your S3 Bucket Permissions, scroll down to the CORS section, and paste in a nice, strict configuration like this (lol I did it via terraform – jfyi):
JSON
[ { "AllowedHeaders": [ "*" ], "AllowedMethods": [ "PUT" ], "AllowedOrigins": [ "https://www.your-actual-app-domain.com" ], "ExposeHeaders": [] }]
A friendly warning: Resist the urge to use
"AllowedOrigins": ["*"]. You are accepting macro-enabled spreadsheets from the public internet. Be specific about exactly which domains are allowed to talk to your bucket.
3. Security and Validation Quirks
Accepting file uploads from users is inherently dangerous. Because you are stuck on an older Python version, you won’t benefit from the implicit security patches found in newer standard libraries. You must defend your uploader manually.
The XML “Billion Laughs” Attack
Because .xlsx files are XML-based, a malicious user can upload a file containing recursively expanding XML entities. When openpyxl tries to parse it, it will consume all available server memory and crash your application.
To prevent this, openpyxl will automatically use defusedxml to guard against quadratic blowup and billion laughs attacks-if it is installed. Always ensure defusedxml is in your environment.
MIME Type vs. Extension Validation
Never trust the file extension alone. A user can rename a .exe file to .xlsx and upload it. Always validate the MIME type of the incoming file stream in your web framework before passing the bytes to your parsing library.
- For .xlsx:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - For .xls:
application/vnd.ms-excel
The Hidden Trap: SSRF and Local File Inclusion (LFI)
One of the most dangerous, overlooked vulnerabilities in bulk uploaders happens after you parse the file. If your Excel sheet contains columns for images or assets (e.g., image_path or logo_url), and your backend attempts to download or resolve those paths, you are opening a massive security hole.
If a user inputs /etc/passwd or an internal AWS metadata IP ([http://169.254.169.254](http://169.254.169.254)) into that cell, and your script blindly fetches it, you have a critical Server-Side Request Forgery (SSRF) or LFI vulnerability.
The fix: Treat all paths in the spreadsheet as untrusted strings. If the spreadsheet references an image, force the user to provide a recognized CDN URL (e.g., an image already safely uploaded via a separate, secure asset endpoint). A browser-uploaded spreadsheet has no business telling your backend to access local disk paths.
4. The Art of Resilient Parsing (Don’t Crash on Row 2)
Once you have safely loaded the file into memory, you have to actually parse the rows.
If there is one universal truth in software engineering, it is this: Excel users do terrible things to spreadsheets. They will type “N/A” in an integer column. They will format dates as text. They will leave weird trailing spaces. If you let raw Python exceptions (like KeyError or ValueError) bubble up and crash your script, your users will hate you. They will fix row 2, re-upload, crash on row 5, re-upload, crash on row 12, and then they will complain to your manager.
Here is the correct architecture for parsing complex bulk uploads in Python 3.8:
Step 1: The “Collect, Don’t Crash” Pattern
Instead of failing immediately, create a structural way to collect every error in the sheet so you can return a complete validation report to the user in a single pass. In Python 3.8, @dataclass is perfect for this.
Python
from dataclasses import dataclassfrom typing import Optional, List, Tuple@dataclassclass RowError: sheet: str row: int message: str column: Optional[str] = None def to_dict(self) -> dict: return {"sheet": self.sheet, "row": self.row, "column": self.column, "message": self.message}
Now, write helper functions that catch type errors and append to a list, rather than raising exceptions:
Python
def _require_int(row: dict, column: str, sheet: str, row_number: int, errors: List[RowError]) -> Optional[int]: value = row.get(column, "") if not value: errors.append(RowError(sheet, row_number, f"'{column}' is required", column)) return None try: return int(value) except (TypeError, ValueError): errors.append(RowError(sheet, row_number, f"'{column}' must be a whole number, got '{value}'", column)) return None
Step 2: Dodging the “Phantom Row” Trap
If a user selects column C in Excel and clicks “Wrap Text”, Excel often saves the document thinking the “used range” of the sheet is 1,048,576 rows long. If you just iterate blindly, you will iterate through a million empty rows until your server dies.
You must track the number of actual data rows seen and impose a hard limit:
Python
MAX_ROWS_PER_SHEET = 5000def _too_many_rows(sheet: str, row_number: int, count: int, errors: List[RowError]) -> bool: # `count` is the number of non-blank rows seen, not the physical Excel row number. if count > MAX_ROWS_PER_SHEET: errors.append(RowError(sheet, row_number, f"exceeds the {MAX_ROWS_PER_SHEET}-row limit")) return True return False
Step 3: Putting It Together with Dataclasses
Parse the raw row dictionaries directly into strongly-typed dataclasses. This guarantees that by the time your code hits the database insertion logic, the data is perfectly clean.
Python
@dataclassclass ParsedOrder: row: int name: str advertiser_id: int trafficker_id: intdef parse_orders(worksheet) -> Tuple[dict, List[RowError]]: sheet = "orders" orders = {} errors = [] row_count = 0 # Assume _iter_rows yields (row_number, dictionary_of_headers_to_values) for row_number, row in _iter_rows(worksheet): name = row.get("order_name", "") if not name: continue # Skip genuinely empty rows row_count += 1 if _too_many_rows(sheet, row_number, row_count, errors): break advertiser_id = _require_int(row, "advertiser_id", sheet, row_number, errors) trafficker_id = _require_int(row, "trafficker_id", sheet, row_number, errors) if advertiser_id is None or trafficker_id is None: continue # We recorded the error, move to the next row if name in orders: errors.append(RowError(sheet, row_number, f"duplicate order_name '{name}'", "order_name")) continue orders[name] = ParsedOrder( row=row_number, name=name, advertiser_id=advertiser_id, trafficker_id=trafficker_id ) return orders, errors
By decoupling the file ingestion (from S3 into memory) from the parsing logic, and by anticipating that the user will give you terrible data, you create a system that doesn’t just survive bad input-it actually helps the user fix it.
Now it took me a while to implement all that, but I am glad of all of the learnings and the fact I didn’t have to upgrade a complex setup to Python 3.8+ (because reasons – I was adding new code to already working code, which earns its keep).
In the end though – the choice is yours.
Leave a comment