Uploading and pinning 100 NFTs on IPFS with Pinata's free tier
For Quantum Genesis we needed somewhere permanent to put 100 PNGs and 100 metadata JSON files. Centralized servers go down and S3 buckets get deleted, but IPFS content stays reachable as long as someone pins it. I'd used ipfs cat on a local node before, but I'd never run a real batch upload, and I had no budget for it. So the whole job came down to one question: could Pinata's free tier actually hold an entire 100-piece collection?
Short answer: yes, comfortably. This post is the tutorial I wish I'd had — account setup, the free-tier limits, single and folder uploads, understanding CIDs, verifying outputs, and the production Python script we actually shipped 100 pieces through. Everything here is what we ran, and it cost exactly $0.

Why IPFS, and why Pinata
IPFS is a decentralized protocol that uses content addressing instead of server addresses. Each file gets a unique hash (CID) computed from its contents. Change one byte and the CID changes, so content linked to a token can never be silently swapped. That guarantee is exactly what you want behind NFT art.
The catch: IPFS nodes only hold files they're explicitly told to keep. Without pinning, content gets garbage-collected and vanishes. Pinata runs IPFS nodes that keep your files available around the clock and serve them through fast gateways. There are alternatives — nft.storage, Infura, web3.storage — but Pinata had the most straightforward API for what we needed. We put all 100 PNGs and 100 metadata JSONs through it, and they've stayed reachable since.
Free tier limits
Pinata's free plan (as of early 2026):
| Feature | Free tier |
|---|---|
| Storage | 1 GB |
| Files | 500 max |
| Bandwidth | Unlimited (gateway) |
| API requests | Unlimited |
| Dedicated gateway | 1 (custom subdomain) |
The math works out cleanly for a 100-piece set. Our 100 PNGs averaged ~150KB each (~15MB total) and the 100 metadata JSONs were under 1KB each — roughly 16MB of the 1GB quota. The trick that keeps you inside the file limit: upload each folder as a single pinned directory, which counts as one pin, not 100. Art as one folder, metadata as another. Two pins total, out of 500.
Account setup and API keys
Create a free account at app.pinata.cloud, then:
- Open API Keys in the sidebar
- Click New Key
- Enable pinFileToIPFS and pinJSONToIPFS permissions (or just grant Admin)
- Name it something like "quantum-genesis-upload"
- Click Create Key
You get three values: API Key, API Secret, and JWT. Save all three immediately — the secret is shown only once. For the Python scripts below we use the JWT, since it's the simplest auth.
# Store as environment variables (never hardcode!)
# Windows:
setx PINATA_JWT "your_jwt_token_here"
setx PINATA_API_KEY "your_api_key"
setx PINATA_SECRET_KEY "your_api_secret"
# Linux/Mac:
export PINATA_JWT="your_jwt_token_here"
You'll also get a dedicated gateway — a URL like https://your-name.mypinata.cloud. That's your personal, fast path to pinned content. Ours was https://maroon-bright-perch-405.mypinata.cloud.
Uploading a single file
One file through the API is the smallest building block:
import requests
import os
PINATA_JWT = os.environ["PINATA_JWT"]
def upload_file(filepath):
url = "https://api.pinata.cloud/pinning/pinFileToIPFS"
headers = {"Authorization": f"Bearer {PINATA_JWT}"}
filename = os.path.basename(filepath)
with open(filepath, "rb") as f:
files = {"file": (filename, f)}
response = requests.post(url, headers=headers, files=files)
data = response.json()
print(f"Uploaded {filename}")
print(f"CID: {data['IpfsHash']}")
print(f"Size: {data['PinSize']} bytes")
return data["IpfsHash"]
# Usage
cid = upload_file("quantum_genesis_001.png")
# Access at: https://your-gateway.mypinata.cloud/ipfs/{cid}
The response returns the CID (IpfsHash), the pin size, and a timestamp. The CID is what you later reference in metadata.
Uploading a folder (art)
For a collection you want all the images inside a single IPFS directory, so each is reachable at ipfs://CID/filename.png:
import requests
import os
PINATA_JWT = os.environ["PINATA_JWT"]
def upload_folder(folder_path, pin_name="nft-art"):
url = "https://api.pinata.cloud/pinning/pinFileToIPFS"
headers = {"Authorization": f"Bearer {PINATA_JWT}"}
files = []
for filename in sorted(os.listdir(folder_path)):
filepath = os.path.join(folder_path, filename)
if os.path.isfile(filepath):
files.append((
"file",
(f"{pin_name}/{filename}", open(filepath, "rb"), "image/png")
))
import json
metadata = json.dumps({"name": pin_name})
options = json.dumps({"wrapWithDirectory": False})
response = requests.post(
url,
headers=headers,
files=files,
data={"pinataMetadata": metadata, "pinataOptions": options}
)
data = response.json()
print(f"Folder uploaded! CID: {data['IpfsHash']}")
for _, (_, fobj, _) in files:
fobj.close()
return data["IpfsHash"]
# Upload all 100 PNGs
art_cid = upload_folder("./nft-output/png/", pin_name="quantum-genesis-art")
# Each image: ipfs://{art_cid}/quantum_genesis_001.png
Our art folder came back as CID bafybeifges7tei5x7drj37f34yhzqofwlz2icbo7z67isg6g446k65yw3a. Each image is then reachable at a full gateway URL like:
https://maroon-bright-perch-405.mypinata.cloud/ipfs/bafybeifges7tei5x7drj37f34yhzqofwlz2icbo7z67isg6g446k65yw3a/quantum_genesis_042.png
Metadata JSONs
Metadata follows the ERC-721 standard: each token needs a JSON file with at minimum name, description, and image. Here's one of ours:
{
"name": "Quantum Genesis #42",
"description": "Generated from quantum measurements on IBM Quantum ibm_fez (156 qubits).",
"image": "ipfs://bafybeifges7tei5x7drj37f34yhzqofwlz2icbo7z67isg6g446k65yw3a/quantum_genesis_042.png",
"attributes": [
{"trait_type": "Processor", "value": "IBM Quantum ibm_fez"},
{"trait_type": "Qubits Used", "value": 8},
{"trait_type": "Entropy Tier", "value": "High"},
{"trait_type": "Entanglement", "value": "GHZ-3"},
{"trait_type": "Color Palette", "value": "Nebula"},
{"trait_type": "Complexity Score", "value": 87}
]
}
Note the image field uses ipfs:// protocol, not an HTTP gateway URL. Marketplaces like OpenSea resolve ipfs:// through their own infrastructure, which keeps the content available long-term.
Upload the metadata the same way as art:
metadata_cid = upload_folder("./nft-output/metadata/", pin_name="quantum-genesis-metadata")
# Each metadata: ipfs://{metadata_cid}/42
The metadata CID becomes the contract's baseTokenURI. When OpenSea calls tokenURI(42), the contract returns ipfs://{metadata_cid}/42, OpenSea fetches the JSON, reads image, and renders the art.
Understanding CIDs
A CID is a cryptographic hash of file contents with two useful properties:
- Deterministic: the same file always yields the same CID.
- Immutable: change one byte and the CID changes completely. That's why CIDs work for provenance — you can mathematically prove content wasn't tampered with.
Two versions exist:
| Version | Prefix | Example |
|---|---|---|
| CIDv0 | Qm... | QmT5NvUtoM5nWFfrQdVrFtvGfKFmG7AHE8P34isapyhCxX |
| CIDv1 | bafy... | bafybeifges7tei5x7drj37f34yhzqofwlz2icbo7z67isg6g446k65yw3a |
Pinata returns CIDv0 for single files and CIDv1 for directories by default. Both work; CIDv1 is the modern standard and plays nicely with subdomain gateways.
Verifying on a gateway
After uploading, verify everything is reachable:
import requests
def verify_uploads(cid, filenames, gateway):
for fname in filenames:
url = f"{gateway}/ipfs/{cid}/{fname}"
r = requests.head(url, timeout=10)
status = "OK" if r.status_code == 200 else f"FAIL ({r.status_code})"
print(f"{fname}: {status}")
verify_uploads(
"bafybeifges7tei5x7drj37f34yhzqofwlz2icbo7z67isg6g446k65yw3a",
[f"quantum_genesis_{i:03d}.png" for i in range(1, 101)],
"https://maroon-bright-perch-405.mypinata.cloud"
)
The production batch script
Here's the script we actually ran. It handles retries, progress tracking, metadata generation, and prints the CID mapping you wire into the contract:
#!/usr/bin/env python3
"""
upload_to_ipfs.py — Batch upload NFT art and metadata to Pinata IPFS
Used for the Quantum Genesis collection (100 NFTs)
"""
import os
import json
import time
import requests
from pathlib import Path
PINATA_JWT = os.environ["PINATA_JWT"]
PINATA_API = "https://api.pinata.cloud/pinning/pinFileToIPFS"
HEADERS = {"Authorization": f"Bearer {PINATA_JWT}"}
MAX_RETRIES = 3
def upload_directory(folder_path, pin_name, file_ext=None):
"""Upload a directory of files as a single IPFS directory pin."""
folder = Path(folder_path)
files_to_upload = sorted(folder.iterdir())
if file_ext:
files_to_upload = [f for f in files_to_upload if f.suffix == file_ext]
print(f"Uploading {len(files_to_upload)} files from {folder_path}...")
file_handles = []
files = []
for filepath in files_to_upload:
fh = open(filepath, "rb")
file_handles.append(fh)
mime = "image/png" if filepath.suffix == ".png" else "application/json"
files.append(("file", (f"{pin_name}/{filepath.name}", fh, mime)))
metadata = json.dumps({"name": pin_name})
options = json.dumps({"wrapWithDirectory": False})
for attempt in range(MAX_RETRIES):
try:
# Reset file positions on retry
for fh in file_handles:
fh.seek(0)
response = requests.post(
PINATA_API,
headers=HEADERS,
files=files,
data={"pinataMetadata": metadata, "pinataOptions": options},
timeout=300 # 5 min timeout for large uploads
)
response.raise_for_status()
data = response.json()
print(f"SUCCESS! CID: {data['IpfsHash']}")
print(f"Pin size: {data['PinSize']} bytes")
for fh in file_handles:
fh.close()
return data["IpfsHash"]
except Exception as e:
print(f"Attempt {attempt+1} failed: {e}")
if attempt < MAX_RETRIES - 1:
time.sleep(5 * (attempt + 1))
for fh in file_handles:
fh.close()
raise Exception(f"Failed after {MAX_RETRIES} retries")
def generate_metadata(art_cid, nft_data_path, output_dir):
"""Generate ERC-721 metadata JSONs pointing to the art CID."""
os.makedirs(output_dir, exist_ok=True)
with open(nft_data_path) as f:
nfts = json.load(f)
for nft in nfts:
token_id = nft["token_id"]
metadata = {
"name": f"Quantum Genesis #{token_id}",
"description": nft.get("description", ""),
"image": f"ipfs://{art_cid}/{nft['filename']}",
"attributes": nft.get("attributes", [])
}
outpath = os.path.join(output_dir, str(token_id))
with open(outpath, "w") as f:
json.dump(metadata, f, indent=2)
print(f"Generated {len(nfts)} metadata files in {output_dir}")
if __name__ == "__main__":
# Step 1: Upload art
art_cid = upload_directory("./nft-output/png/", "quantum-genesis-art", ".png")
# Step 2: Generate metadata pointing to art CID
generate_metadata(art_cid, "./_nft_data.json", "./nft-output/metadata/")
# Step 3: Upload metadata
meta_cid = upload_directory("./nft-output/metadata/", "quantum-genesis-metadata")
print(f"\n{'='*60}")
print(f"Art CID: {art_cid}")
print(f"Metadata CID: {meta_cid}")
print(f"Base URI: ipfs://{meta_cid}/")
print(f"{'='*60}")
print(f"\nSet this as your contract's baseTokenURI:")
print(f" contract.setBaseTokenURI('ipfs://{meta_cid}/')")
Lessons from batch-uploading 100 pieces
- Always upload as directories. Individual file pins eat the 500-file quota fast; a directory of 100 counts as one pin.
- Prefer PNG over SVG. OpenSea and most marketplaces render PNGs reliably; SVGs sometimes render wrong or not at all. We generated SVG, then rasterized to PNG with headless Chrome (because cairosvg mishandled our complex SVGs on Windows) — I wrote that whole battle up in the Selenium conversion post.
- Art first, then metadata. Metadata references the art CID, so you need it before generating the JSON.
- Set a long timeout. Uploading 15MB+ of PNGs takes a few minutes; our script used 5 minutes.
- Verify every file after upload. We ran HEAD requests against the gateway for all 100 and found zero failures. Worth doing anyway.
- Use
ipfs://in metadata, not HTTP gateway URLs. Marketplaces resolve the protocol through their own stack. - Keep CIDs safe. Lose the CID and you lose the link between token and content. Ours live in the repo and env vars.

The whole upload — 100 PNGs and 100 metadata files — took about ten minutes and cost exactly $0, and the content has stayed pinned and reachable through our gateway since. The next step after storage was turning that metadata into a pricing model, which is the data-driven pricing post. If your upload hits an edge case this didn't cover, tell me what broke.
Comments
Post a Comment