Requests y APIs en Python: consumir servicios web con requests
Hasta ahora hemos estado trabajando con datos que ya tenemos: archivos en disco, DataFrames que construimos nosotros, números que generamos con NumPy. Pero el grueso de los datos reales vive en servidores remotos, detrás de APIs HTTP que esperan peticiones y devuelven respuestas JSON. Saber consumir esas APIs es una habilidad tan fundamental como manejar listas o escribir funciones. Y en Python, la herramienta para hacerlo se llama requests.
En esta lección aprendemos a hacer peticiones HTTP desde Python de forma profesional: GET para consultar, POST para crear, PUT para modificar, DELETE para eliminar. Veremos cómo autenticarse, cómo manejar los errores correctamente, cómo trabajar con APIs reales y cómo usar sesiones para optimizar conexiones. Al final, podrás conectar tus programas Python con cualquier servicio web del planeta.
¿Qué es requests y por qué no urllib?
Python incluye en su librería estándar el módulo urllib para hacer peticiones HTTP. Funciona, pero nadie que lo haya usado y haya probado después requests ha vuelto. Kenneth Reitz publicó requests en 2011 con un eslogan que todavía aparece en su documentación: «HTTP for Humans». No era marketing: era una declaración de intenciones.
La diferencia es visceral. Mira el mismo trabajo hecho con cada uno:
# ── Con urllib (librería estándar) ────────────────────────────
import urllib.request
import urllib.parse
import json
params = urllib.parse.urlencode({'q': 'python', 'per_page': 5})
url = 'https://api.github.com/search/repositories?' + params
req = urllib.request.Request(url, headers={'Accept': 'application/json'})
with urllib.request.urlopen(req) as response:
data = json.loads(response.read().decode('utf-8'))
print(data['total_count'])
# ── Con requests (lo mismo, pero legible) ─────────────────────
import requests
resp = requests.get('https://api.github.com/search/repositories',
params={'q': 'python', 'per_page': 5},
headers={'Accept': 'application/json'})
data = resp.json()
print(data['total_count'])
requests gestiona automáticamente la codificación de parámetros en la URL, la decodificación de la respuesta, las redirecciones, la compresión gzip y las cookies. Tú te concentras en qué quieres hacer, no en cómo funciona HTTP.
pip install requests
httpx es el sucesor moderno de requests con soporte para async/await y HTTP/2. Si tu proyecto ya usa asyncio (FastAPI, scripts concurrentes), empieza directamente con httpx. Su API es casi idéntica a requests: todo lo que aprendas aquí aplica allí. Para scripts síncronos y código que no necesita concurrencia, requests es suficiente y tiene más documentación disponible.
Tu primera petición GET
GET es el método para obtener recursos: consultar datos, buscar, listar. Es el método que usa el navegador cuando visitas una URL. En requests, requests.get(url) devuelve un objeto Response con toda la información de la respuesta.
import requests
# ── La petición más simple posible ────────────────────────────
resp = requests.get('https://jsonplaceholder.typicode.com/posts/1')
# El objeto Response contiene todo lo que devolvió el servidor
print(resp.status_code) # 200
print(resp.url) # https://jsonplaceholder.typicode.com/posts/1
print(resp.elapsed) # tiempo que tardó: 0:00:00.243412
# El cuerpo de la respuesta
print(resp.text) # el body como string (texto plano)
print(resp.content) # el body como bytes (para imágenes, PDFs, etc.)
print(resp.json()) # el body parseado como dict/list Python
# Las cabeceras de la respuesta
print(resp.headers['Content-Type']) # 'application/json; charset=utf-8'
print(resp.headers['X-Powered-By']) # el servidor a veces se delata aquí
# ── Comprobación rápida de éxito ──────────────────────────────
if resp.ok: # True si status_code < 400
datos = resp.json()
print(datos['title'])
# ── Un ejemplo más completo con JSONPlaceholder ───────────────
# JSONPlaceholder es una API pública de prueba sin autenticación
BASE = 'https://jsonplaceholder.typicode.com'
# Obtener lista de posts
posts = requests.get(f'{BASE}/posts').json()
print(f"Total posts: {len(posts)}") # 100
print(posts[0]) # {'userId': 1, 'id': 1, 'title': ..., 'body': ...}
# Obtener un usuario específico
usuario = requests.get(f'{BASE}/users/3').json()
print(usuario['name']) # Clementine Bauch
print(usuario['address']['city']) # McKenziehaven
La respuesta: status, headers y JSON
El objeto Response merece atención detenida. Los códigos de estado HTTP no son caprichosos: tienen una semántica precisa que las APIs bien diseñadas respetan. Conocerlos te ahorra horas de depuración.
import requests
resp = requests.get('https://jsonplaceholder.typicode.com/posts/1')
# ── Códigos de estado HTTP más importantes ────────────────────
# 2xx: Éxito
# 200 OK — petición correcta, respuesta en el body
# 201 Created — recurso creado (POST exitoso)
# 204 No Content — éxito pero sin body (DELETE exitoso, a veces PUT)
# 3xx: Redirecciones (requests las sigue automáticamente)
# 301 Moved Permanently — la URL ha cambiado definitivamente
# 302 Found — redirección temporal
# 4xx: Error del cliente (tú enviaste algo malo)
# 400 Bad Request — parámetros malformados, JSON inválido
# 401 Unauthorized — no te has autenticado
# 403 Forbidden — autenticado pero sin permiso
# 404 Not Found — el recurso no existe
# 405 Method Not Allowed — ese endpoint no admite ese método HTTP
# 409 Conflict — conflicto de estado (p.ej. email duplicado)
# 422 Unprocessable — validación fallida (FastAPI lo usa mucho)
# 429 Too Many Requests — has superado el rate limit
# 5xx: Error del servidor (el problema es suyo, no tuyo)
# 500 Internal Server Error — algo explotó en el servidor
# 502 Bad Gateway — proxy o load balancer no alcanzó el backend
# 503 Service Unavailable — servidor caído o sobrecargado
# 504 Gateway Timeout — el backend tardó demasiado
print(resp.status_code) # 200
# ── Inspeccionar cabeceras ─────────────────────────────────────
# Las cabeceras son un dict insensible a mayúsculas/minúsculas
print(resp.headers) # todas las cabeceras
print(resp.headers.get('content-type')) # 'application/json; charset=utf-8'
print(resp.headers.get('x-ratelimit-remaining', 'N/A')) # si existe
# ── Parsear JSON ───────────────────────────────────────────────
# .json() deserializa automáticamente la respuesta
datos = resp.json() # dict o list, según la API
print(type(datos)) #
print(datos.keys()) # dict_keys(['userId', 'id', 'title', 'body'])
# Acceso seguro con .get() para campos opcionales
titulo = datos.get('title', 'Sin título')
# ── Respuestas de lista ────────────────────────────────────────
todos = requests.get('https://jsonplaceholder.typicode.com/todos').json()
print(type(todos)) #
print(len(todos)) # 200
completados = [t for t in todos if t['completed']]
print(f"Completados: {len(completados)} de {len(todos)}")
# ── Encoding manual si .json() falla ──────────────────────────
# En el raro caso de que el servidor no declare charset correctamente:
resp.encoding = 'utf-8'
datos = resp.json() # ahora con encoding correcto
resp.json() además gestiona correctamente el encoding declarado en la cabecera Content-Type. Siempre usa resp.json(). La única excepción es cuando necesitas capturar explícitamente un json.JSONDecodeError —en ese caso puede ser más claro rodear el parse manualmente con try/except.
Parámetros, cabeceras y autenticación
La mayoría de las APIs tienen dos modos de recibir información: en la URL (query parameters, para filtros y paginación en GET) y en el cuerpo del mensaje (para crear o modificar datos con POST/PUT). Las cabeceras llevan metainformación: tipo de contenido que esperas, token de autenticación, idioma preferido.
import requests
# ── Query parameters ───────────────────────────────────────────
# MAL: construir la URL a mano (tedioso y propenso a errores)
url = 'https://api.github.com/search/repositories?q=python+machine+learning&sort=stars&per_page=5'
# BIEN: pasar un dict, requests codifica automáticamente
resp = requests.get(
'https://api.github.com/search/repositories',
params={
'q': 'python machine learning',
'sort': 'stars',
'order': 'desc',
'per_page': 5,
}
)
print(resp.url) # la URL real con los params codificados correctamente
repos = resp.json()['items']
for r in repos:
print(f"⭐ {r['stargazers_count']:,} {r['full_name']}")
# ── Cabeceras personalizadas ───────────────────────────────────
headers = {
'Accept': 'application/vnd.github.v3+json', # versión de API a usar
'User-Agent': 'MiApp/1.0 (contacto@miapp.com)', # algunas APIs lo requieren
}
resp = requests.get(
'https://api.github.com/users/gvanrossum', # Guido van Rossum en GitHub
headers=headers
)
guido = resp.json()
print(guido['name']) # Guido van Rossum
print(guido['public_repos']) # número de repositorios públicos
# ── Autenticación: HTTP Basic ──────────────────────────────────
# Para APIs que usan usuario + contraseña
resp = requests.get(
'https://httpbin.org/basic-auth/usuario/clave',
auth=('usuario', 'clave') # requests envía la cabecera Authorization: Basic ...
)
print(resp.json()['authenticated']) # True
# ── Autenticación: Bearer Token (JWT / OAuth2) ────────────────
# Es el método más común en APIs modernas (GitHub, OpenAI, Stripe...)
TOKEN = 'ghp_TuTokenDeGitHubAqui' # ejemplo: Personal Access Token de GitHub
resp = requests.get(
'https://api.github.com/user',
headers={'Authorization': f'Bearer {TOKEN}'}
)
# Equivalente más corto para GitHub y muchas APIs:
resp = requests.get(
'https://api.github.com/user',
headers={'Authorization': f'token {TOKEN}'}
)
# ── Autenticación: API Key en cabecera ────────────────────────
# Patrón de OpenAI, Stripe, SendGrid, etc.
API_KEY = 'sk-tuclaveaqui'
resp = requests.get(
'https://api.openweathermap.org/data/2.5/weather',
params={'q': 'Madrid,ES', 'units': 'metric', 'lang': 'es'},
headers={'x-api-key': API_KEY}
)
# ── Autenticación: API Key en parámetro ───────────────────────
# Algunas APIs la llevan como query param (OpenWeatherMap, por ejemplo)
resp = requests.get(
'https://api.openweathermap.org/data/2.5/weather',
params={'q': 'Madrid,ES', 'units': 'metric', 'lang': 'es', 'appid': API_KEY}
)
import os; TOKEN = os.environ['GITHUB_TOKEN']. En Python-dotenv puedes cargarlas de un archivo .env que añades al .gitignore. Publicar un token en GitHub activa alertas automáticas y el token queda comprometido en segundos —los bots escanean nuevos commits en tiempo real.
POST, PUT y DELETE: modificar recursos
GET consulta. POST crea. PUT reemplaza. PATCH modifica parcialmente. DELETE elimina. Esta semántica es el corazón de REST. Entenderla bien te permite diseñar clientes que se comportan de forma predecible y documentar integraciones que otros desarrolladores puedan entender sin explicación.
import requests
BASE = 'https://jsonplaceholder.typicode.com'
# ── POST: crear un nuevo recurso ──────────────────────────────
# json= serializa automáticamente el dict y añade Content-Type: application/json
nuevo_post = {
'title': 'Aprendiendo requests en Python',
'body': 'La librería requests hace que trabajar con HTTP sea humano.',
'userId': 1,
}
resp = requests.post(f'{BASE}/posts', json=nuevo_post)
print(resp.status_code) # 201 Created
creado = resp.json()
print(creado['id']) # el ID asignado por el servidor (101 en JSONPlaceholder)
# ── PUT: reemplazar un recurso completo ────────────────────────
# PUT debe enviar la representación COMPLETA del recurso
post_actualizado = {
'id': 1,
'title': 'Título actualizado',
'body': 'Cuerpo completamente reemplazado.',
'userId': 1,
}
resp = requests.put(f'{BASE}/posts/1', json=post_actualizado)
print(resp.status_code) # 200 OK
print(resp.json()['title']) # Título actualizado
# ── PATCH: modificar campos concretos ─────────────────────────
# PATCH envía solo los campos que quieres cambiar
resp = requests.patch(f'{BASE}/posts/1', json={'title': 'Solo cambio el título'})
print(resp.status_code) # 200 OK
print(resp.json()) # el recurso completo con solo el título cambiado
# ── DELETE: eliminar un recurso ───────────────────────────────
resp = requests.delete(f'{BASE}/posts/1')
print(resp.status_code) # 200 (JSONPlaceholder) ó 204 en APIs reales
# Si la respuesta es 204, resp.text estará vacío — no llames a .json()
# ── POST con form data (en lugar de JSON) ─────────────────────
# Algunos endpoints antiguos o de autenticación usan x-www-form-urlencoded
resp = requests.post(
'https://httpbin.org/post',
data={ # data= en lugar de json= → Content-Type: application/x-www-form-urlencoded
'username': 'ana',
'password': 'mi_clave',
}
)
print(resp.json()['form']) # {'username': 'ana', 'password': 'mi_clave'}
# ── POST con subida de archivo ─────────────────────────────────
# files= gestiona el Content-Type multipart/form-data automáticamente
with open('informe.pdf', 'rb') as f:
resp = requests.post(
'https://httpbin.org/post',
files={'archivo': ('informe.pdf', f, 'application/pdf')},
data={'descripcion': 'Informe mensual'} # campos de texto adicionales
)
print(resp.json()['files']) # {'archivo': '...contenido base64...'}
Errores, excepciones y timeouts
En HTTP, una respuesta 404 o 500 no es una excepción Python: es una respuesta válida con un código de estado que indica fallo. requests no lanza excepciones automáticamente por códigos de error —a menos que le digas que lo haga. Esto es una decisión de diseño: te da el control de decidir qué hacer con cada tipo de fallo.
import requests
from requests.exceptions import (
ConnectionError, # no se pudo conectar al servidor
Timeout, # se agotó el tiempo de espera
TooManyRedirects, # bucle infinito de redirecciones
HTTPError, # raise_for_status() detectó 4xx/5xx
RequestException, # base de todas las excepciones de requests
)
URL = 'https://jsonplaceholder.typicode.com/posts/1'
# ── raise_for_status(): convertir 4xx/5xx en excepciones ─────
resp = requests.get(URL)
resp.raise_for_status() # lanza HTTPError si status >= 400
# no hace nada si el status es 2xx
# ── Patrón completo de manejo de errores ──────────────────────
def fetch_post(post_id: int) -> dict | None:
try:
resp = requests.get(
f'https://jsonplaceholder.typicode.com/posts/{post_id}',
timeout=5 # segundos: lanza Timeout si tarda más
)
resp.raise_for_status() # lanza HTTPError para 4xx/5xx
return resp.json()
except Timeout:
print(f"[ERROR] El servidor tardó más de 5s en responder.")
return None
except ConnectionError:
print(f"[ERROR] No se pudo conectar. ¿Tienes internet?")
return None
except HTTPError as e:
codigo = e.response.status_code
if codigo == 404:
print(f"[ERROR] Post {post_id} no existe.")
elif codigo == 429:
print(f"[ERROR] Rate limit superado. Espera antes de reintentar.")
else:
print(f"[ERROR] HTTP {codigo}: {e}")
return None
except RequestException as e:
# Captura cualquier otro error de requests
print(f"[ERROR] Problema con la petición: {e}")
return None
post = fetch_post(1)
if post:
print(post['title'])
# ── Timeout: connect vs read ──────────────────────────────────
# timeout=5 → 5s para conectar Y para leer la respuesta
# timeout=(3, 10) → 3s para conectar, 10s para leer
resp = requests.get(URL, timeout=(3, 10))
# ── Reintentos automáticos con HTTPAdapter ────────────────────
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def crear_sesion_robusta() -> requests.Session:
"""Sesión con reintentos automáticos en errores de red."""
sesion = requests.Session()
reintentos = Retry(
total=3, # máximo 3 intentos
backoff_factor=0.5, # espera 0.5s, 1s, 2s entre reintentos
status_forcelist=[429, 500, 502, 503, 504], # reintentar con estos códigos
allowed_methods=['GET', 'POST'],
)
adaptador = HTTPAdapter(max_retries=reintentos)
sesion.mount('https://', adaptador)
sesion.mount('http://', adaptador)
return sesion
session = crear_sesion_robusta()
resp = session.get(URL, timeout=10)
timeout=(3, 10) es un buen punto de partida para la mayoría de APIs.
APIs públicas reales: ejemplos prácticos
La teoría se asienta con ejemplos reales. Aquí tres APIs públicas con distintos patrones de autenticación y respuesta que puedes probar ahora mismo desde tu terminal.
import requests
import os
# ── 1. PokeAPI — sin autenticación, paginación ────────────────
# Una de las APIs públicas más usadas para aprender. Completamente gratis.
def obtener_pokemon(nombre: str) -> dict:
resp = requests.get(
f'https://pokeapi.co/api/v2/pokemon/{nombre.lower()}',
timeout=10
)
resp.raise_for_status()
datos = resp.json()
return {
'nombre': datos['name'],
'altura': datos['height'] / 10, # dm → metros
'peso': datos['weight'] / 10, # hg → kg
'tipos': [t['type']['name'] for t in datos['types']],
'stats': {s['stat']['name']: s['base_stat'] for s in datos['stats']},
}
pikachu = obtener_pokemon('pikachu')
print(f"{pikachu['nombre']}: {pikachu['tipos']}, {pikachu['peso']} kg")
# pikachu: ['electric'], 6.0 kg
# Listar los primeros 20 pokémon con paginación
resp = requests.get('https://pokeapi.co/api/v2/pokemon',
params={'limit': 20, 'offset': 0}, timeout=10)
listado = resp.json()
print(f"Total pokémon: {listado['count']}") # 1302 aproximadamente
for p in listado['results']:
print(p['name'])
# ── 2. GitHub API — autenticación opcional, rate limit ────────
# Sin token: 60 peticiones/hora. Con token: 5.000 peticiones/hora.
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', '') # opcional
headers_gh = {}
if GITHUB_TOKEN:
headers_gh['Authorization'] = f'Bearer {GITHUB_TOKEN}'
# Repositorios más populares de Python
resp = requests.get(
'https://api.github.com/search/repositories',
params={'q': 'language:python', 'sort': 'stars', 'per_page': 5},
headers=headers_gh,
timeout=10
)
resp.raise_for_status()
resultados = resp.json()
print(f"Repositorios Python en GitHub: {resultados['total_count']:,}")
for repo in resultados['items']:
print(f" ⭐ {repo['stargazers_count']:>7,} {repo['full_name']}")
# Comprobar rate limit restante
limite = resp.headers.get('X-RateLimit-Remaining', '?')
print(f"\nPeticiones restantes en esta hora: {limite}")
# ── 3. Open-Meteo — meteorología gratuita, sin API key ───────
# API de meteorología de código abierto. Sin registro, sin límites razonables.
def tiempo_actual(ciudad: str, lat: float, lon: float) -> dict:
resp = requests.get(
'https://api.open-meteo.com/v1/forecast',
params={
'latitude': lat,
'longitude': lon,
'current': 'temperature_2m,wind_speed_10m,precipitation',
'wind_speed_unit': 'kmh',
'timezone': 'Europe/Madrid',
},
timeout=10
)
resp.raise_for_status()
actual = resp.json()['current']
return {
'ciudad': ciudad,
'temperatura': actual['temperature_2m'],
'viento_kmh': actual['wind_speed_10m'],
'precipitacion': actual['precipitation'],
}
madrid = tiempo_actual('Madrid', lat=40.4168, lon=-3.7038)
bcn = tiempo_actual('Barcelona', lat=41.3851, lon=2.1734)
for ciudad in [madrid, bcn]:
print(f"{ciudad['ciudad']}: {ciudad['temperatura']}°C, "
f"viento {ciudad['viento_kmh']} km/h, "
f"precipitación {ciudad['precipitacion']} mm")
Sessions y patrones avanzados
Cada llamada a requests.get() abre y cierra una conexión TCP nueva. Cuando haces muchas peticiones al mismo servidor, ese overhead se acumula. El objeto Session mantiene la conexión abierta (HTTP keep-alive), reutiliza la configuración común (headers, auth, cookies) y puede ser configurado con reintentos automáticos. Es el patrón correcto para cualquier código de producción que haga más de unas pocas peticiones.
import requests
# ── Session básica ────────────────────────────────────────────
# Todo lo que configuras en la sesión se aplica a todas sus peticiones
with requests.Session() as sesion:
# Configurar una vez, usar siempre
sesion.headers.update({
'Authorization': 'Bearer mi_token_aqui',
'Accept': 'application/json',
'User-Agent': 'MiApp/2.0',
})
sesion.params = {'lang': 'es'} # query param añadido a todas las peticiones
# Ahora todas las peticiones llevan los headers y params configurados
post1 = sesion.get('https://jsonplaceholder.typicode.com/posts/1').json()
post2 = sesion.get('https://jsonplaceholder.typicode.com/posts/2').json()
post3 = sesion.get('https://jsonplaceholder.typicode.com/posts/3').json()
# La conexión TCP se reutiliza entre las tres peticiones → más rápido
# ── Session con cookies automáticas ───────────────────────────
# La sesión guarda las cookies que el servidor devuelve y las reenvía
# Es cómo se mantiene una "sesión" de usuario en APIs con autenticación por cookies
with requests.Session() as s:
# Login (servidor devuelve cookie de sesión)
s.post('https://httpbin.org/cookies/set/sesion_id/abc123')
# Siguiente petición lleva la cookie automáticamente
resp = s.get('https://httpbin.org/cookies')
print(resp.json()) # {'cookies': {'sesion_id': 'abc123'}}
# ── Respuestas grandes: streaming ─────────────────────────────
# Para descargar archivos grandes sin cargarlos en memoria de golpe
def descargar_archivo(url: str, ruta_local: str) -> None:
"""Descarga un archivo en chunks de 8KB sin saturar la memoria."""
with requests.get(url, stream=True, timeout=30) as resp:
resp.raise_for_status()
tamano_total = int(resp.headers.get('Content-Length', 0))
descargado = 0
with open(ruta_local, 'wb') as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
descargado += len(chunk)
if tamano_total:
pct = descargado / tamano_total * 100
print(f"\rDescargando... {pct:.1f}%", end='', flush=True)
print(f"\n✓ Descargado en {ruta_local}")
# ── Inspeccionar la petición antes de enviarla ────────────────
req = requests.Request(
method='POST',
url='https://api.ejemplo.com/datos',
json={'clave': 'valor'},
headers={'Authorization': 'Bearer token123'},
params={'version': 'v2'},
)
preparada = req.prepare()
print(preparada.url) # URL con params codificados
print(preparada.headers) # todos los headers, incluyendo Content-Type auto
print(preparada.body) # cuerpo JSON serializado
# ── Patrón cliente de API reutilizable ────────────────────────
class ClienteGitHub:
"""Wrapper de la API de GitHub con sesión persistente."""
BASE = 'https://api.github.com'
def __init__(self, token: str):
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'MiClienteGitHub/1.0',
})
def obtener_usuario(self, username: str) -> dict:
resp = self.session.get(f'{self.BASE}/users/{username}', timeout=10)
resp.raise_for_status()
return resp.json()
def listar_repos(self, username: str, n: int = 5) -> list[dict]:
resp = self.session.get(
f'{self.BASE}/users/{username}/repos',
params={'sort': 'stars', 'per_page': n},
timeout=10
)
resp.raise_for_status()
return resp.json()
def __enter__(self):
return self
def __exit__(self, *args):
self.session.close()
# Uso del cliente
import os
TOKEN = os.environ.get('GITHUB_TOKEN', '')
if TOKEN:
with ClienteGitHub(TOKEN) as gh:
guido = gh.obtener_usuario('gvanrossum')
print(f"{guido['name']}: {guido['public_repos']} repos públicos")
for repo in gh.listar_repos('gvanrossum', n=3):
print(f" ⭐ {repo['stargazers_count']:,} {repo['name']}")
❓ Preguntas frecuentes
❓ Preguntas frecuentes sobre Requests y APIs en Python: consumir servicios web con requests
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Requests y APIs en Python: consumir servicios web con requests? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!