2. Upload your first file

The files= parameter on requests.post() hides the entire multipart/form-data machinery behind a single dictionary. When you point at a file handle, requests generates the boundary string, assembles the parts, and sets the right Content-Type header. This section walks through the simple case (a text file to httpbin), then layers on metadata, then builds the ProfilePictureUploader class with magic-byte validation, a file-size cap, and safe filenames before any byte hits the network.

Your first upload

Save the following at the project root as basic_upload.py. It writes a one-line text file, opens it in binary mode, and posts it to httpbin.org, which echoes the request back so you can see exactly what the server received:

basic_upload.py
import requests

# 1. Create a test file
with open('test.txt', 'w') as f:
    f.write("This is a test file for upload.")

# 2. Open in binary mode and upload
with open('test.txt', 'rb') as f:
    files = {'file': f}
    response = requests.post("https://httpbin.org/post", files=files, timeout=10)

# 3. Inspect the response
print(f"Status: {response.status_code}")
print(f"Content-Type sent: {response.request.headers.get('Content-Type')}")
print(f"File received by server: {response.json()['files']}")
Terminal
Status: 200
Content-Type sent: multipart/form-data; boundary=...
File received by server: {'file': 'This is a test file for upload.'}

What just happened

  • Binary mode. We opened the file with 'rb' even though it is text. The same call works for any file type, so make it the default.
  • Automatic encoding. requests saw the files= parameter and switched the request to Content-Type: multipart/form-data with a generated boundary string.
  • Dictionary format. {'file': f} tells the server to expose the upload under the field name file. The key on the left is the form field name; the value on the right is the file handle.

That three-line shape (open in 'rb', build a files= dict, post it) is the foundation for every upload in this chapter. The mechanics stay the same; only the validation, metadata, and error handling grow.

Adding metadata to your uploads

Real applications usually send a file alongside extra fields: a user ID, a description, a tag list. You attach those by passing both files= and data= to the same request. Save this as upload_with_metadata.py:

upload_with_metadata.py
import requests

# Note: You need an actual image file for this
# For testing, create a small dummy binary file:
with open('profile.jpg', 'wb') as f:
    f.write(b'\xFF\xD8\xFF\xE0')  # JPEG header bytes

# Upload with metadata
with open('profile.jpg', 'rb') as f:
    files = {'profile_image': f}
    data = {
        'user_id': '12345',
        'description': 'Profile photo taken at conference'
    }
    
    response = requests.post(
        "https://httpbin.org/post", 
        files=files,
        data=data,  # Metadata goes in 'data', not 'json'
        timeout=15
    )

result = response.json()
print(f"Files: {result['files']}")
print(f"Form data: {result['form']}")
Terminal
Files: {'profile_image': ...}
Form data: {'user_id': '12345', 'description': 'Profile photo taken at conference'}

When you pass both files= and data=, requests assembles a multipart/form-data body that interleaves form fields and file content, separated by random boundary strings. Here is what the wire format looks like:

Diagram showing the structure of a multipart form-data HTTP POST request. The request is divided into sections by boundary strings. Two sections show form fields ('user_id' and 'description'). The final section shows file data, including the filename 'profile.jpg' and binary content.
Boundary strings separate form fields from file bytes.

The pieces of that body are worth naming because they show up in error messages and tcpdump output:

  • Boundary strings. Random separators (like ----WebKitFormBoundary...) divide the parts of the request so the server can tell where each form field or file begins and ends.
  • Form fields. Each key in your data= dictionary becomes its own part, containing the field name and the value as text.
  • File data. The file gets its own part with the filename, a per-file Content-Type header, and the actual binary bytes.
  • End boundary. A final boundary marks the close of the multipart message so the server stops reading.
You cannot mix files= and json=

A single HTTP request has one Content-Type header. files= switches the request to multipart/form-data; json= wants application/json. The two are mutually exclusive. If you need to send metadata alongside an upload, put it in data= as form fields, not json=. This catches a lot of developers the first time, usually as a confusing 400 from the server.

Building ProfilePictureUploader

The class below combines the patterns from this section (binary-mode reads, multipart/form-data uploads) with three production guards: an extension allow-list, a 5MB size cap, and magic-byte validation that catches a .txt file renamed to .jpg before it reaches the network. Save it as profile_picture_uploader.py:

profile_picture_uploader.py
import os
from pathlib import Path
import mimetypes
import requests

class ProfilePictureUploader:
    """
    A production-style profile picture uploader with:
      - Extension and size limits
      - Basic header validation (magic bytes)
      - Safe filenames
      - Proper MIME types
      - Clear (success, message, url) return structure
    """

    # Maximum file size: 5MB
    MAX_FILE_SIZE = 5 * 1024 * 1024

    # Allowed image extensions
    ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif'}

    def __init__(self, user_id):
        self.user_id = user_id

    def upload_profile_picture(self, filepath):
        """
        Upload a profile picture with comprehensive validation.

        Returns:
            tuple: (success: bool, message: str, url: str or None)
        """

        # STEP 1: File existence check
        if not os.path.exists(filepath):
            return (False, "File not found", None)

        # STEP 2: Extension validation
        file_ext = Path(filepath).suffix.lower()
        if file_ext not in self.ALLOWED_EXTENSIONS:
            allowed = ', '.join(self.ALLOWED_EXTENSIONS)
            return (False, f"Invalid file type. Allowed: {allowed}", None)

        # STEP 3: File size validation
        file_size = os.path.getsize(filepath)
        if file_size > self.MAX_FILE_SIZE:
            max_mb = self.MAX_FILE_SIZE / (1024 * 1024)
            actual_mb = file_size / (1024 * 1024)
            return (False, f"File too large ({actual_mb:.1f}MB). Max: {max_mb}MB", None)

        # STEP 4: Basic content validation (is it really an image?)
        try:
            with open(filepath, 'rb') as f:
                header = f.read(10)

            if not self._is_valid_image_header(header, file_ext):
                return (False, "File content does not match a valid image format", None)
        except Exception as e:
            return (False, f"Failed to read file header: {str(e)}", None)

        # STEP 5: Prepare safe filename
        safe_filename = self._build_safe_filename(file_ext)

        # STEP 6: Determine correct MIME type
        mime_type = self._get_mime_type(file_ext)

        # STEP 7: Upload to remote API
        try:
            with open(filepath, 'rb') as f:
                files = {
                    'file': (safe_filename, f, mime_type)
                }

                response = requests.post(
                    "https://httpbin.org/post",
                    files=files,
                    timeout=30
                )
                response.raise_for_status()

                # In a real system, you'd parse the response to get the final URL.
                # Here we just return a pretend URL.
                uploaded_url = f"https://cdn.example.com/profile_pics/{safe_filename}"
                return (True, "Upload successful", uploaded_url)

        except requests.exceptions.Timeout:
            return (False, "Upload timed out - please try again", None)

        except requests.exceptions.RequestException as e:
            return (False, f"Upload failed: {str(e)}", None)

    def _is_valid_image_header(self, header, extension):
        """Check if file header matches the claimed extension"""
        # JPEG: FF D8 FF
        if extension in {'.jpg', '.jpeg'} and header[:3] == b'\xFF\xD8\xFF':
            return True
        # PNG: 89 50 4E 47 0D 0A 1A 0A
        if extension == '.png' and header[:8] == b'\x89PNG\r\n\x1a\n':
            return True
        # GIF: 47 49 46 38 37 61 or 47 49 46 38 39 61
        if extension == '.gif' and header[:6] in (b'GIF87a', b'GIF89a'):
            return True
        return False

    def _get_mime_type(self, extension):
        """Get proper MIME type for extension"""
        mime_types = {
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.png': 'image/png',
            '.gif': 'image/gif'
        }
        return mime_types.get(extension, 'application/octet-stream')

    def _build_safe_filename(self, extension):
        """
        Build a safe filename that does not leak original names
        (which might contain personal data).
        """
        return f"user_{self.user_id}_profile{extension}"

# Usage Example
if __name__ == "__main__":
    # Create a tiny fixture so the success path runs end-to-end.
    # Real apps receive the path from a file-upload form or CLI argument.
    test_image = "test_profile_picture.jpg"
    with open(test_image, "wb") as f:
        f.write(b"\xFF\xD8\xFF\xE0" + b"\x00" * 1024)  # JPEG header + filler

    uploader = ProfilePictureUploader(user_id=12345)
    success, message, url = uploader.upload_profile_picture(test_image)
    print("Success:", success)
    print("Message:", message)
    print("URL:", url)

The class posts to httpbin.org/post, which echoes the request back so you can inspect what the server received but does not actually host the upload. The uploaded_url string returned on success (https://cdn.example.com/profile_pics/...) is a placeholder for what a real CDN would give you. In production you would parse the response body to pull out the actual hosted URL.

Testing the failure paths

Run the script as-is to see the success path. Then point it at the kinds of files real users will throw at it (a 10MB image, a .txt file renamed to .jpg, a file whose first bytes do not match the claimed extension) and watch each guard catch the problem before requests.post() sees a single byte:

uploader = ProfilePictureUploader(user_id=12345)
success, message, url = uploader.upload_profile_picture("my_photo.jpg")

The point isn't that magic-byte validation is bulletproof. A determined attacker can prepend FF D8 FF to anything and pass that check, which is why production uploads still need server-side virus scanning. The point is that the cheap checks (extension, size, header signature) catch the overwhelming majority of casual rename mistakes and reduce the load on the expensive checks downstream.

In production you would usually upload to dedicated object storage (S3, Google Cloud Storage, Azure Blob Storage) rather than your app server's local disk. The validation pattern stays identical; only the destination URL and the SDK call change.