Cómo trabajar con archivos JSON en Python: leer, escribir y validar

D
DanisCh
• 11 min de lectura
Cómo trabajar con archivos JSON en Python: leer, escribir y validar
Python

JSON es el formato de intercambio de datos más usado en el desarrollo web moderno. Las APIs REST devuelven JSON, los archivos de configuración usan JSON, las bases de datos NoSQL almacenan JSON. Python incluye el módulo json en su librería estándar, así que no necesitas instalar nada para trabajar con él. En esta guía aprenderás a leer, escribir, modificar y validar JSON desde cero.

Los fundamentos: json.loads y json.dumps

Hay dos operaciones básicas: convertir un string JSON a un objeto Python (deserializar) y convertir un objeto Python a un string JSON (serializar).

import json

# json.loads: string JSON → objeto Python ("loads" = load string)
texto_json = '{"nombre": "Ana", "edad": 28, "activo": true, "notas": null}'

datos = json.loads(texto_json)

print(datos)          # {'nombre': 'Ana', 'edad': 28, 'activo': True, 'notas': None}
print(type(datos))    # 
print(datos["nombre"])  # Ana
print(datos["activo"])  # True  (JSON true → Python True)
print(datos["notas"])   # None  (JSON null → Python None)

# Equivalencias JSON ↔ Python:
# JSON object   → dict
# JSON array    → list
# JSON string   → str
# JSON number   → int o float
# JSON true     → True
# JSON false    → False
# JSON null     → None
import json

# json.dumps: objeto Python → string JSON ("dumps" = dump string)
datos = {
    "nombre": "Carlos",
    "edad":   35,
    "activo": False,
    "notas":  None,
    "tags":   ["python", "backend"]
}

texto = json.dumps(datos)
print(texto)
# {"nombre": "Carlos", "edad": 35, "activo": false, "notas": null, "tags": ["python", "backend"]}

# Con formato legible (indent)
texto_legible = json.dumps(datos, indent=2, ensure_ascii=False)
print(texto_legible)
# {
#   "nombre": "Carlos",
#   "edad": 35,
#   "activo": false,
#   "notas": null,
#   "tags": [
#     "python",
#     "backend"
#   ]
# }

# ensure_ascii=False: permite caracteres no ASCII (tildes, ñ, etc.)
# Sin él: "Ángel" → "Ángel"

Leer y escribir archivos JSON

Para trabajar con archivos en disco, usa json.load y json.dump (sin la "s" final): leen y escriben directamente desde un objeto de archivo.

import json

# Leer un archivo JSON
with open("usuarios.json", "r", encoding="utf-8") as f:
    usuarios = json.load(f)

print(type(usuarios))   # list o dict, dependiendo del JSON
print(usuarios[0])      # primer elemento si es una lista

# Siempre usa encoding="utf-8" para evitar problemas con tildes y ñ
import json

# Escribir un archivo JSON
usuarios = [
    {"id": 1, "nombre": "Ana García",   "email": "ana@mail.com"},
    {"id": 2, "nombre": "Carlos López", "email": "carlos@mail.com"},
]

with open("usuarios.json", "w", encoding="utf-8") as f:
    json.dump(usuarios, f, indent=2, ensure_ascii=False)

# El archivo resultante:
# [
#   {
#     "id": 1,
#     "nombre": "Ana García",
#     "email": "ana@mail.com"
#   },
#   ...
# ]

Función reutilizable para leer y escribir

import json
from pathlib import Path

def leer_json(ruta: str) -> dict | list:
    """Lee un archivo JSON y devuelve su contenido."""
    with open(ruta, "r", encoding="utf-8") as f:
        return json.load(f)

def escribir_json(ruta: str, datos: dict | list, indent: int = 2) -> None:
    """Escribe datos en un archivo JSON, creando directorios si hacen falta."""
    Path(ruta).parent.mkdir(parents=True, exist_ok=True)
    with open(ruta, "w", encoding="utf-8") as f:
        json.dump(datos, f, indent=indent, ensure_ascii=False)

# Uso
config = leer_json("config.json")
config["version"] = "2.0"
escribir_json("config.json", config)

Modificar un archivo JSON existente

import json

# Patrón: leer → modificar → escribir
# (no hay modo de edición in situ para JSON)

# 1. Leer el archivo actual
with open("config.json", "r", encoding="utf-8") as f:
    config = json.load(f)

# 2. Modificar el objeto Python
config["version"]          = "2.1.0"
config["debug"]            = False
config["nuevaClave"]       = "nuevo valor"
del config["claveObsoleta"]   # eliminar una clave

# Si es una lista: añadir, filtrar, ordenar
config["plugins"].append("nuevo-plugin")
config["plugins"] = [p for p in config["plugins"] if p != "plugin-viejo"]

# 3. Escribir de vuelta
with open("config.json", "w", encoding="utf-8") as f:
    json.dump(config, f, indent=2, ensure_ascii=False)

Actualización segura: backup antes de sobrescribir

import json
import shutil
from datetime import datetime

def actualizar_json_seguro(ruta: str, cambios: dict) -> None:
    """Actualiza un JSON haciendo backup previo."""
    # Hacer backup
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    shutil.copy(ruta, f"{ruta}.{timestamp}.bak")

    with open(ruta, "r", encoding="utf-8") as f:
        datos = json.load(f)

    # Actualización profunda (merge recursivo)
    def merge(base, actualizacion):
        for clave, valor in actualizacion.items():
            if clave in base and isinstance(base[clave], dict) and isinstance(valor, dict):
                merge(base[clave], valor)
            else:
                base[clave] = valor

    merge(datos, cambios)

    with open(ruta, "w", encoding="utf-8") as f:
        json.dump(datos, f, indent=2, ensure_ascii=False)

# Uso
actualizar_json_seguro("config.json", {
    "version": "2.2.0",
    "base_de_datos": {
        "pool_size": 20   # solo actualiza pool_size dentro de base_de_datos
    }
})

Manejar errores comunes

import json

# Error 1: JSON malformado
texto_invalido = '{"nombre": "Ana", "edad": 28'   # le falta la llave de cierre

try:
    datos = json.loads(texto_invalido)
except json.JSONDecodeError as e:
    print(f"JSON inválido: {e}")
    print(f"  Línea: {e.lineno}, columna: {e.colno}")
    print(f"  Posición: {e.pos}")
    # JSON inválido: Expecting ',' delimiter: line 1 column 29 (char 28)

# Error 2: archivo no existe
try:
    with open("no_existe.json", "r", encoding="utf-8") as f:
        datos = json.load(f)
except FileNotFoundError:
    print("El archivo no existe")
    datos = {}   # valor por defecto

# Error 3: tipo no serializable
from datetime import datetime

datos = {"nombre": "Ana", "fecha": datetime.now()}

try:
    json.dumps(datos)
except TypeError as e:
    print(f"No se puede serializar: {e}")
    # Object of type datetime is not JSON serializable

Serializar tipos que JSON no soporta por defecto

import json
from datetime import datetime, date
from decimal import Decimal
from enum import Enum

class EncoderPersonalizado(json.JSONEncoder):
    """Encoder que soporta datetime, date, Decimal y Enum."""

    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()        # "2026-10-07T14:30:00"
        if isinstance(obj, date):
            return obj.isoformat()        # "2026-10-07"
        if isinstance(obj, Decimal):
            return float(obj)             # Decimal("19.99") → 19.99
        if isinstance(obj, Enum):
            return obj.value              # Color.ROJO → "rojo"
        return super().default(obj)

# Uso
datos = {
    "usuario":   "Ana",
    "creado_en": datetime.now(),
    "precio":    Decimal("29.99"),
}

texto = json.dumps(datos, cls=EncoderPersonalizado, indent=2)
print(texto)
# {
#   "usuario": "Ana",
#   "creado_en": "2026-10-07T14:30:00.123456",
#   "precio": 29.99
# }

# También se puede usar el parámetro default con una función:
def serializar(obj):
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    raise TypeError(f"Tipo no serializable: {type(obj)}")

json.dumps(datos, default=serializar)

Validar JSON con jsonschema

El módulo json solo verifica que el JSON sea sintácticamente correcto. Para validar que los datos tienen la estructura y los tipos esperados (que "edad" es un número positivo, que "email" tiene formato de email, que ciertos campos son obligatorios), usa jsonschema.

# pip install jsonschema

import json
import jsonschema
from jsonschema import validate, ValidationError

# Definir el esquema
esquema_usuario = {
    "type": "object",
    "required": ["nombre", "email", "edad"],
    "properties": {
        "nombre": {
            "type":      "string",
            "minLength": 1,
            "maxLength": 100
        },
        "email": {
            "type":   "string",
            "format": "email"
        },
        "edad": {
            "type":    "integer",
            "minimum": 0,
            "maximum": 150
        },
        "activo": {
            "type": "boolean"
        },
        "rol": {
            "type": "string",
            "enum": ["admin", "editor", "lector"]   # solo estos valores
        }
    },
    "additionalProperties": False   # no se permiten campos extra
}

# Validar datos correctos
usuario_valido = {
    "nombre": "Ana García",
    "email":  "ana@mail.com",
    "edad":   28,
    "activo": True,
    "rol":    "editor"
}

try:
    validate(instance=usuario_valido, schema=esquema_usuario)
    print("✓ Datos válidos")
except ValidationError as e:
    print(f"✗ Error: {e.message}")

# Validar datos incorrectos
usuario_invalido = {
    "nombre": "",           # minLength falla: cadena vacía
    "email":  "no-es-email",
    "edad":   -5,           # minimum falla: negativo
}

try:
    validate(instance=usuario_invalido, schema=esquema_usuario)
except ValidationError as e:
    print(f"✗ {e.message}")
    print(f"  Campo: {' → '.join(str(p) for p in e.path)}")
    # ✗ '' is too short
    #   Campo: nombre
# Validar todos los errores de una vez (no solo el primero)
from jsonschema import Draft7Validator

validator = Draft7Validator(esquema_usuario)
errores = list(validator.iter_errors(usuario_invalido))

if errores:
    print(f"Se encontraron {len(errores)} error(es):")
    for error in errores:
        campo = " → ".join(str(p) for p in error.path) or "(raíz)"
        print(f"  - {campo}: {error.message}")
else:
    print("Datos válidos")

# Se encontraron 3 error(es):
#   - nombre: '' is too short
#   - email: 'no-es-email' is not a 'email'
#   - edad: -5 is less than the minimum of 0

Validar JSON con Pydantic (alternativa moderna)

# pip install pydantic[email]
# Pydantic valida con clases Python, más ergonómico que jsonschema para APIs

from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional, Literal
from datetime import datetime

class Usuario(BaseModel):
    nombre:  str          = Field(min_length=1, max_length=100)
    email:   EmailStr
    edad:    int          = Field(ge=0, le=150)
    activo:  bool         = True
    rol:     Literal["admin", "editor", "lector"] = "lector"
    creado_en: Optional[datetime] = None

    @field_validator("nombre")
    @classmethod
    def nombre_no_vacio(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("El nombre no puede ser solo espacios")
        return v.strip()

# Parsear y validar desde dict
try:
    usuario = Usuario(**{
        "nombre": "Ana García",
        "email":  "ana@mail.com",
        "edad":   28,
        "rol":    "editor"
    })
    print(usuario)
    print(usuario.model_dump())         # → dict Python
    print(usuario.model_dump_json())    # → string JSON

except Exception as e:
    print(f"Error de validación: {e}")

# Parsear desde JSON directamente
json_texto = '{"nombre": "Carlos", "email": "carlos@mail.com", "edad": 35}'
usuario = Usuario.model_validate_json(json_texto)

Casos prácticos habituales

Leer una respuesta JSON de una API

import urllib.request
import json

# Obtener datos de una API pública
url = "https://jsonplaceholder.typicode.com/users/1"

with urllib.request.urlopen(url) as respuesta:
    datos = json.loads(respuesta.read().decode("utf-8"))

print(datos["name"])          # Leanne Graham
print(datos["address"]["city"])  # Gwenborough

# Con la librería requests (más cómoda):
# import requests
# respuesta = requests.get(url)
# datos = respuesta.json()   # convierte automáticamente

Archivo de configuración con valores por defecto

import json
from pathlib import Path

DEFAULTS = {
    "debug":     False,
    "puerto":    8000,
    "base_datos": {
        "host":      "localhost",
        "puerto":    5432,
        "nombre":    "mi_app",
        "pool_size": 10
    },
    "log_level": "INFO"
}

def cargar_config(ruta: str = "config.json") -> dict:
    """Carga la configuración, usando defaults para claves ausentes."""
    config = DEFAULTS.copy()

    if Path(ruta).exists():
        with open(ruta, "r", encoding="utf-8") as f:
            config_usuario = json.load(f)
        # Merge: los valores del usuario sobreescriben los defaults
        config.update(config_usuario)
    else:
        # Crear el archivo con los defaults si no existe
        with open(ruta, "w", encoding="utf-8") as f:
            json.dump(DEFAULTS, f, indent=2)
        print(f"Creado {ruta} con configuración por defecto")

    return config

config = cargar_config()
print(config["puerto"])          # 8000 (del default si no está en el archivo)
print(config["base_datos"]["host"])  # localhost

Procesar una lista grande de registros JSON (JSON Lines)

# JSON Lines (.jsonl): un objeto JSON por línea, ideal para logs o datasets grandes
# No carga todo el archivo en memoria de una vez

# Ejemplo de archivo logs.jsonl:
# {"timestamp": "2026-10-07T10:00:00", "nivel": "INFO",  "mensaje": "Servidor iniciado"}
# {"timestamp": "2026-10-07T10:01:00", "nivel": "ERROR", "mensaje": "BD no disponible"}
# {"timestamp": "2026-10-07T10:02:00", "nivel": "INFO",  "mensaje": "Reintentando..."}

def leer_jsonl(ruta: str):
    """Generador que lee un archivo JSON Lines línea a línea."""
    with open(ruta, "r", encoding="utf-8") as f:
        for numero, linea in enumerate(f, 1):
            linea = linea.strip()
            if not linea:
                continue
            try:
                yield json.loads(linea)
            except json.JSONDecodeError as e:
                print(f"Línea {numero} inválida: {e}")

def escribir_jsonl(ruta: str, registros) -> None:
    """Escribe registros en formato JSON Lines."""
    with open(ruta, "w", encoding="utf-8") as f:
        for registro in registros:
            f.write(json.dumps(registro, ensure_ascii=False) + "\n")

# Uso: contar errores sin cargar todo el archivo
errores = sum(
    1 for log in leer_jsonl("logs.jsonl")
    if log.get("nivel") == "ERROR"
)
print(f"Total de errores: {errores}")

Resumen

  • Usa json.loads() para convertir un string JSON a un objeto Python y json.dumps() para lo contrario. Para archivos en disco, usa json.load(f) y json.dump(datos, f) con un objeto de archivo abierto. Incluye siempre encoding="utf-8" y ensure_ascii=False para manejar correctamente tildes y caracteres especiales.
  • Modificar un archivo JSON siempre sigue el patrón leer → modificar el objeto Python → escribir de vuelta. No hay edición in situ.
  • Los tipos que JSON no soporta directamente (datetime, Decimal, Enum) requieren un encoder personalizado: pasa cls=TuEncoder o default=tu_funcion a json.dumps().
  • Para validar la estructura y los tipos de los datos (no solo la sintaxis JSON), usa jsonschema cuando trabajas con JSON de forma directa, o pydantic cuando construyes APIs: Pydantic es más ergonómico y genera mensajes de error más claros.
  • Para archivos muy grandes con muchos registros (logs, datasets), usa el formato JSON Lines (.jsonl): un objeto JSON por línea, procesado con un generador para no cargar todo el archivo en memoria.

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a