Quick answer: Use JSON with the json module for save data, platformdirs for cross-platform save paths, os.replace() for atomic writes, and include a version number in every save file for forward migration.

Pygame does not include a built-in save system, which means you build your own from scratch using Python’s standard library. The good news is Python makes file I/O straightforward. The bad news is there are many ways to get it wrong—wrong paths, corrupted writes, insecure deserialization, and format changes that break old saves. This guide covers how to do it right.

JSON for Save Data

Python’s built-in json module is the best default for save files. It produces human-readable output, is safe to parse, and handles all the basic types you need for game state.

import json
from pathlib import Path

def save_game(path: Path, data: dict) -> bool:
    try:
        json_str = json.dumps(data, indent=2)
        tmp_path = path.with_suffix(".json.tmp")
        tmp_path.write_text(json_str, encoding="utf-8")
        tmp_path.replace(path)  # Atomic rename
        return True
    except OSError as e:
        print(f"Save failed: {e}")
        return False

def load_game(path: Path) -> dict | None:
    if not path.exists():
        return None
    try:
        json_str = path.read_text(encoding="utf-8")
        data = json.loads(json_str)
        return migrate_save(data)
    except (json.JSONDecodeError, OSError) as e:
        print(f"Load failed: {e}")
        return None

Platform-Specific Save Paths

Never save to the game’s installation directory or the current working directory. These may not be writable, and on some platforms the working directory changes depending on how the game is launched.

Use the platformdirs library (install with pip install platformdirs) to get the correct platform-specific directory:

from platformdirs import user_data_dir
from pathlib import Path

def get_save_dir() -> Path:
    save_dir = Path(user_data_dir("MyGameName", "MyStudioName"))
    save_dir.mkdir(parents=True, exist_ok=True)
    return save_dir

# Windows: C:\Users\<user>\AppData\Local\MyStudioName\MyGameName
# macOS:   ~/Library/Application Support/MyGameName
# Linux:   ~/.local/share/MyGameName

If you do not want the platformdirs dependency, you can use os.path.expanduser("~") combined with platform detection, but platformdirs handles edge cases and follows each platform’s conventions correctly.

Avoid Pickle for Save Files

Python’s pickle module can serialize almost any Python object, which makes it tempting for save files. But pickle has a critical problem: loading a pickle file can execute arbitrary code. If a player downloads a save file from the internet, or if save files are shared in a modding community, a malicious pickle can compromise their machine.

Stick with JSON for save data. If you have complex types like custom classes, write explicit serialization methods:

class Player:
    def __init__(self, x: float, y: float, health: int, inventory: list):
        self.x = x
        self.y = y
        self.health = health
        self.inventory = inventory

    def to_dict(self) -> dict:
        return {
            "x": self.x,
            "y": self.y,
            "health": self.health,
            "inventory": self.inventory
        }

    @classmethod
    def from_dict(cls, data: dict):
        return cls(
            x=data["x"],
            y=data["y"],
            health=data["health"],
            inventory=data.get("inventory", [])
        )

Structuring Save Data

Use Python dataclasses to define a clear save schema. This makes your save format self-documenting and catches type errors early.

from dataclasses import dataclass, field, asdict
from typing import List

@dataclass
class SaveData:
    version: int = 1
    timestamp: str = ""
    player_x: float = 0.0
    player_y: float = 0.0
    health: int = 100
    gold: int = 0
    current_level: str = "start"
    inventory: List[str] = field(default_factory=list)
    quests_completed: List[str] = field(default_factory=list)

# Saving
save = SaveData(
    timestamp=datetime.utcnow().isoformat(),
    player_x=player.rect.x,
    player_y=player.rect.y,
    health=player.health,
    gold=player.gold
)
save_game(save_path, asdict(save))

# Loading
data = load_game(save_path)
if data:
    save = SaveData(**data)

Save File Versioning

Include a version number and write migration functions for each version step:

CURRENT_VERSION = 2

def migrate_save(data: dict) -> dict:
    version = data.get("version", 0)

    if version < 1:
        # v0 -> v1: renamed "coins" to "gold"
        if "coins" in data:
            data["gold"] = data.pop("coins")

    if version < 2:
        # v1 -> v2: added quests_completed
        data.setdefault("quests_completed", [])

    data["version"] = CURRENT_VERSION
    return data

Atomic Writes

The save_game function above already uses atomic writes with Path.replace(). This is critical: if the game crashes while writing, you get either the old file or the new file, never a half-written file.

For additional safety, keep a backup:

import shutil

def save_game_with_backup(path: Path, data: dict) -> bool:
    try:
        json_str = json.dumps(data, indent=2)
        tmp_path = path.with_suffix(".json.tmp")
        bak_path = path.with_suffix(".json.bak")

        # Write to temp
        tmp_path.write_text(json_str, encoding="utf-8")

        # Backup current save
        if path.exists():
            shutil.copy2(path, bak_path)

        # Atomic replace
        tmp_path.replace(path)
        return True
    except OSError as e:
        print(f"Save failed: {e}")
        return False

Multiple Save Slots

Store each slot as a separate file in the save directory:

def slot_path(slot: int) -> Path:
    return get_save_dir() / f"slot_{slot}.json"

def list_slots(max_slots: int = 3) -> list:
    slots = []
    for i in range(max_slots):
        path = slot_path(i)
        if path.exists():
            data = load_game(path)
            slots.append({
                "slot": i,
                "empty": False,
                "timestamp": data.get("timestamp", "Unknown"),
                "level": data.get("current_level", "Unknown")
            })
        else:
            slots.append({"slot": i, "empty": True})
    return slots

def delete_slot(slot: int) -> None:
    path = slot_path(slot)
    path.unlink(missing_ok=True)
    path.with_suffix(".json.bak").unlink(missing_ok=True)

SQLite for Complex Saves

For games with large amounts of data—hundreds of NPCs with individual state, procedurally generated worlds, or complex quest systems—SQLite can be a better choice than a single JSON file. Python includes sqlite3 in the standard library.

import sqlite3

def init_save_db(path: Path) -> sqlite3.Connection:
    conn = sqlite3.connect(path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS game_state (
            key TEXT PRIMARY KEY,
            value TEXT NOT NULL
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS npcs (
            id TEXT PRIMARY KEY,
            x REAL, y REAL,
            health INTEGER,
            state TEXT
        )
    """)
    conn.commit()
    return conn

def save_state(conn: sqlite3.Connection, key: str, value) -> None:
    conn.execute(
        "INSERT OR REPLACE INTO game_state (key, value) VALUES (?, ?)",
        (key, json.dumps(value))
    )
    conn.commit()

SQLite supports transactions, so you can wrap an entire save operation in a transaction for atomicity. It also handles concurrent access better than flat files if your game uses threading.

Autosave

Implement autosave by tracking elapsed time in your game loop:

AUTOSAVE_INTERVAL = 300  # seconds

class Game:
    def __init__(self):
        self.autosave_timer = 0.0
        self.clock = pygame.time.Clock()

    def update(self):
        dt = self.clock.tick(60) / 1000.0
        self.autosave_timer += dt

        if self.autosave_timer >= AUTOSAVE_INTERVAL:
            self.autosave_timer = 0.0
            save_game(slot_path(0), self.gather_save_data())
            print("Autosave complete")

Also save when the player quits. Handle the pygame.QUIT event and save before calling pygame.quit().

Related Issues

For debugging save corruption across all engines, see How to Debug Game Save Corruption Bugs. For general error logging practices, read Best Practices for Game Error Logging.

Use json not pickle, platformdirs not hardcoded paths, and os.replace not direct writes. Python makes it easy to build a solid save system from standard library tools. The most common mistake is saving next to the executable instead of in the user data directory.