Right, you want to get data out of your program and into the world. We’ve covered simple forms, but sometimes you need to send more than just a few key-value pairs—you need to send a file. This is where multipart/form-data enters the chat, looking slightly complex but actually being a rather elegant solution to a messy problem.

Think about it: how do you send a JPEG image and a JSON string in the same request without everything turning into a corrupted soup? You can’t just shove them together. The multipart format solves this by acting like an electronic envelope containing multiple separate documents, each with its own metadata (like a name and content type). It uses a unique “boundary” string to separate these parts, a concept so simple it’s brilliant. The HTTP client libraries handle the gory details of constructing this for you, but it’s crucial to understand what’s happening under the hood so you can debug it when, not if, it goes sideways.

The Anatomy of a Multipart Request

Before we let a library do the work, let’s look at what we’re actually building. When you create a multipart request, the Content-Type header gets a value like this:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

That boundary parameter is the magic string that will be used to separate each part. The body of the request then looks something like this:

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="metadata"
Content-Type: application/json

{"description": "A picture of my very good dog"}
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="image"; filename="dog.jpg"
Content-Type: image/jpeg

<raw binary data of the image file here>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

See how the boundary acts as a delimiter? The final boundary has two trailing dashes to indicate the end. Now, you will almost never write this by hand, thank goodness. But when your upload mysteriously fails, being able to peek at the raw request in a tool like Wireshark or by logging it is often the only way to figure out if the server is rejecting you because of a missing header, a malformed boundary, or incorrect part names.

Doing It in Python with Requests

The requests library makes this blissfully simple. You create a dictionary for your form data and a dictionary for your files, and it handles the rest, including generating a random boundary and setting the correct Content-Type header. This is why we love it.

import requests

# Your standard form data
form_data = {
    'username': 'chris',
    'description': 'A picture of my very good dog'
}

# Your file. The tuple allows you to specify filename and content-type explicitly.
# If you just open the file, requests is smart enough to figure it out, but be explicit.
# It's cleaner and avoids a whole class of server-side parsing errors.
files = {
    'avatar': ('dog.jpg', open('dog.jpg', 'rb'), 'image/jpeg')
}

response = requests.post('https://httpbin.org/post', data=form_data, files=files)
print(response.json())

The most common pitfall here is forgetting to open the file in binary mode ('rb'). If you open it in text mode (just 'r'), on Windows, you might corrupt the file by converting line endings, turning your perfect JPEG into digital gibberish. Always use 'rb'.

When Things Get Tricky: The Advanced Multipart

Sometimes, the server is… finicky. Perhaps it expects a specific part name, or you need to send a file that isn’t just a simple on-disk file (like an in-memory buffer). Or maybe you need to send multiple files. requests has you covered.

import requests
from io import BytesIO

# Sending multiple files with the same field name (common for "attachments")
files = [
    ('images', ('dog1.jpg', open('dog1.jpg', 'rb'), 'image/jpeg')),
    ('images', ('dog2.jpg', open('dog2.jpg', 'rb'), 'image/jpeg'))
]

# Sending JSON data as a part? Yes, you can. Just set the content-type.
data = {'message': 'simple field'}
files = {
    'metadata': ('metadata.json', BytesIO(b'{"type": "complex", "value": 42}'), 'application/json')
}

response = requests.post('https://example.com/upload', data=data, files=files)

Another classic “oops” moment is not resetting the pointer of a BytesIO object. If you wrote data to it already, the pointer is at the end, and you’ll send zero bytes. Do bytes_io.seek(0) before adding it to the files dict.

The Requests-Toolbelt for Very Large Files

For massive files, the standard requests approach can be inefficient because it reads the entire file into memory before sending it. This is where the fantastic requests-toolbelt library and its MultipartEncoder come to the rescue. It streams the data, which is much more memory-efficient.

from requests_toolbelt import MultipartEncoder
import requests

# The encoder handles the boundary and streaming for you
encoder = MultipartEncoder({
    'field': 'value',
    'file': ('filename.jpg', open('huge_file.jpg', 'rb'), 'image/jpeg')
})

response = requests.post(
    'https://example.com/upload',
    data=encoder,  # Pass the encoder directly as the data
    headers={'Content-Type': encoder.content_type}  # Crucial: set the header from the encoder
)

The non-negotiable best practice here is to let the MultipartEncoder set the Content-Type header for you. If you don’t, the server won’t know about the custom boundary it generated, and your request will be rejected. It’s a simple one-liner, but it’s the most common mistake when moving to this method. Remember: with great power (streaming) comes great responsibility (setting your headers correctly).