
Cómo leer un paquete de la Descarga Masiva del SAT: de base64 a ZIP, el TXT de Metadata delimitado por tilde y el XML del CFDI, en Python
El `Paquete` de la Descarga llega en base64: decodifícalo y descomprímelo como ZIP — en memoria está bien. Un request de Metadata te da un `.txt` delimitado por tilde (`~`) — un índice con UUID, RFC, fechas, Monto, EfectoComprobante y Estatus; uno de CFDI te da el XML firmado. La única trampa: los nombres traen `~`, saltos de línea y comillas, así que `line.split("~")` desalinea las columnas — parsea a la defensiva.
La respuesta corta: el Paquete es base64 → decodifícalo a ZIP; la Metadata es un TXT índice delimitado por tilde
El Paquete que te devuelve la Descarga llega en base64: decodifícalo y ábrelo como ZIP — en memoria está bien, no necesitas escribirlo en disco. Dentro hay uno de dos formatos, según qué pediste. Una solicitud de Metadata te deja un .txt delimitado por tilde (~): un índice ligero con UUID, RFC, fechas, Monto, EfectoComprobante y Estatus. Una solicitud de CFDI te deja el XML firmado, uno por comprobante. La única trampa real es que los nombres (NombreEmisor, NombreReceptor) pueden traer ~, saltos de línea y comillas, así que un line.split("~") ingenuo te desalinea las columnas — hay que parsear a la defensiva.
Este post asume que ya lograste bajar el paquete. Si el portal muestra CFDI pero tu código baja un ZIP vacío, ese es otro problema y lo cubro aparte en por qué el paquete llega vacío y cómo arreglarlo. Aquí solo hablamos de cómo LEER lo que ya tienes en las manos.
De base64 a ZIP: haz b64decode del Paquete y descomprímelo en memoria (y cuándo el ZIP abre vacío)
El campo Paquete de la respuesta de Descarga no son bytes de ZIP: es un string en base64. El error más común aquí es guardar ese string tal cual en un archivo .zip — abre “vacío” o inválido porque nunca lo decodificaste. Corre base64.b64decode primero y luego trabaja los bytes. Como el índice de Metadata pesa poco, descomprimir en memoria con io.BytesIO te evita ensuciar el disco.
import base64
import io
import zipfile
# `paquete` is the base64 string from the <Paquete> element of the Descarga response
zip_bytes = base64.b64decode(paquete)
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
names = zf.namelist()
if not names:
raise ValueError("empty ZIP: check NumeroCFDIs in VerificaSolicitud")
for name in names: # Metadata -> one .txt; CFDI -> many .xml
with zf.open(name) as f:
member = f.read() # bytes for this member
Si el ZIP abre pero namelist() viene vacío, no es un bug de descompresión: es que el Servicio Web no encontró comprobantes. El valor que decide todo es NumeroCFDIs en VerificaSolicitud. Si vale 0, tu consulta del WS no es la misma consulta que ves en el portal — casi siempre por un rfcSolicitante en el rol equivocado (emitidas vs recibidas) o un rango de fechas sin la hora completa. Ese diagnóstico completo está en el post del paquete vacío y en el de códigos de error y “solicitud aceptada”. Solo llama a Descarga cuando EstadoSolicitud = 3 (Terminada).
Metadata vs CFDI: el índice ligero para acotar vs el XML firmado completo para validar — qué solicitud mandar y cuándo
Cuando armas la solicitud eliges el tipo: Metadata o CFDI. No es lo mismo y conviene entender la diferencia antes de bajar gigas que no necesitas. La Metadata es un índice: te dice el UUID, quién emitió, quién recibió, cuándo, cuánto y en qué estatus está cada comprobante — y pesa alrededor de mil veces menos que el XML completo. El CFDI es el comprobante real: el XML firmado que después vas a VALIDAR (sello, vigencia, listas 69-B).
La estrategia de producción es pedir Metadata primero para acotar, decidir qué UUIDs te importan, y solo entonces pedir el CFDI de ese subconjunto. Así no bajas 200,000 XML cuando en realidad querías los 4,000 vigentes de tipo Ingreso de un mes.
Metadata (el índice)
- Un .txt delimitado por tilde ~ con encabezado
- UUID, RFC emisor/receptor, fechas, Monto, EfectoComprobante, Estatus
- ~1000x más ligero que el XML completo
- Ideal para contar, filtrar y decidir qué bajar
CFDI (el original)
- El XML firmado, uno por comprobante
- Trae sello, certificado y TimbreFiscalDigital
- Es lo que validas (sello/vigencia/69-B)
- Pesado: pídelo solo del subconjunto que acotaste
El formato del TXT de Metadata: el nombre <UUID>-N.txt, el delimitador ~, el encabezado y las 12 columnas
Dentro del ZIP de una solicitud de Metadata viene un archivo .txt con un nombre tipo <UUID>-<consecutivo>.txt, por ejemplo 622CEE0C-8BBA-4273-B02A-B8789FD27F62-0000.txt. El archivo está delimitado por tilde (~), no por coma ni por tab, y trae una fila de encabezado. Las 12 columnas, en orden, son:
Uuid ~ RfcEmisor ~ NombreEmisor ~ RfcReceptor ~ NombreReceptor ~ RfcPac ~
FechaEmision ~ FechaCertificacionSat ~ Monto ~ EfectoComprobante ~
Estatus ~ FechaCancelacion
Dos columnas son códigos que vas a decodificar seguido. EfectoComprobante te dice el tipo de comprobante y Estatus te dice si sigue vivo:
Monto viene como texto (respétalo tal cual y conviértelo tú), FechaEmision y FechaCertificacionSat son fechas del SAT en hora del centro de México, y RfcPac es el RFC del PAC que timbró. Nada de esto reemplaza al XML: la Metadata es el índice, no la fuente de verdad fiscal.
La trampa al parsear: ~, CR/LF y comillas dentro de los nombres rompen line.split("~") — un parser defensivo
Aquí es donde se cae el código ingenuo. NombreEmisor y NombreReceptor son texto libre que capturó otra persona, y pueden contener el propio delimitador ~, saltos de línea (CR/LF) y comillas. Si haces line.split("~") esperando 12 pedazos, una razón social con un ~ de más te va a regresar 13 y todo lo que sigue se recorre una columna: de pronto tu Monto es una fecha y tu Estatus es un RFC. Es un bug silencioso y feo, documentado en el issue #23 de phpcfdi/sat-ws-descarga-masiva.
La defensa es no confiar en el conteo. Salta el encabezado, parte cada línea por ~, y cualquier fila que no dé exactamente 12 campos se va a un bucket de sospechosas en vez de corromper tus datos en silencio:
import re
COLUMNS = [
"Uuid", "RfcEmisor", "NombreEmisor", "RfcReceptor", "NombreReceptor",
"RfcPac", "FechaEmision", "FechaCertificacionSat", "Monto",
"EfectoComprobante", "Estatus", "FechaCancelacion",
]
N = len(COLUMNS) # 12
RFC = re.compile(r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$")
def clean(v: str) -> str:
# CR/LF and quotes leak in from the Nombre* fields
return v.replace("\r", " ").replace("\n", " ").strip().strip('"')
def parse_metadata(text: str):
good, suspect = [], []
for line in text.splitlines()[1:]: # skip the header row
if not line.strip():
continue
fields = line.split("~")
if len(fields) == N:
good.append(dict(zip(COLUMNS, (clean(x) for x in fields))))
else:
suspect.append(fields) # a name leaked a delimiter
return good, suspect
Las filas sospechosas no son basura: casi siempre solo uno de los dos nombres traía un ~. Como las dos primeras columnas (Uuid, RfcEmisor) y las últimas siete (de RfcPac a FechaCancelacion) sí están limpias, puedes reparar la fila anclando por ambos extremos y encontrando el RfcReceptor por su forma:
def repair(fields: list[str]) -> dict:
if len(fields) <= N:
raise ValueError("too few fields — likely an embedded newline, merge lines first")
uuid, rfc_emisor = fields[0], fields[1]
tail = fields[-7:] # RfcPac ... FechaCancelacion
middle = fields[2:-7] # NombreEmisor ~ RfcReceptor ~ NombreReceptor
idx = next(i for i, v in enumerate(middle) if RFC.match(v.strip()))
row = [uuid, rfc_emisor,
"~".join(middle[:idx]), # NombreEmisor (rejoined)
middle[idx], # RfcReceptor
"~".join(middle[idx + 1:]), # NombreReceptor (rejoined)
*tail]
return dict(zip(COLUMNS, (clean(x) for x in row)))
Un último caso: un salto de línea dentro de un nombre parte un registro en dos líneas físicas, y entonces la fila sospechosa trae menos de 12 campos — por eso repair avisa en vez de adivinar. Cuando pase, junta la línea corta con la siguiente antes de partir. La regla de oro: si una fila no cuadra, no adivines dónde va cada valor. Soy ingeniero, no tu contador ni tu abogado: este parser te ordena los datos, pero el criterio fiscal y el formato oficial siempre verifícalos contra sat.gob.mx.
Carga las filas en una lista o DataFrame para filtrar, contar y acotar antes de bajar el XML
Con los diccionarios ya parseados, un DataFrame te deja contar, filtrar y decidir en segundos. Este es el paso donde la Metadata gana su lugar: aquí decides qué UUIDs valen la pena antes de pedir un solo XML.
import pandas as pd
from decimal import Decimal
good, suspect = parse_metadata(member.decode("utf-8"))
df = pd.DataFrame(good)
df["Monto"] = df["Monto"].map(Decimal) # never float for money
vigentes = df[df["Estatus"] == "Vigente"]
ingresos = vigentes[vigentes["EfectoComprobante"] == "I"]
uuids_to_pull = ingresos["Uuid"].tolist()
print(len(uuids_to_pull), "UUIDs to request as CFDI")
Ahora uuids_to_pull es tu lista corta. En vez de bajar todo el rango, pides el CFDI solo de esos UUIDs — menos tráfico, menos almacenamiento y menos XML que validar. Si vas a orquestar esto para varios RFC de un despacho, el patrón completo está en el post del pipeline de producción para múltiples RFC.
El lado del CFDI: parsea cada XML firmado en Python — saca UUID/RFC/Total del TimbreFiscalDigital y pásalo a validación
Cuando pides una solicitud de tipo CFDI (en lugar de Metadata), el ZIP trae XML de verdad: un archivo por comprobante, no un .txt. Cada XML es el CFDI firmado completo. Para reconciliar contra tu índice de Metadata, saca el UUID del nodo TimbreFiscalDigital y los RFC del emisor y receptor del cuerpo del comprobante con ElementTree:
import xml.etree.ElementTree as ET
NS = {
"cfdi": "http://www.sat.gob.mx/cfd/4",
"tfd": "http://www.sat.gob.mx/TimbreFiscalDigital",
}
def read_cfdi(xml_bytes: bytes) -> dict:
root = ET.fromstring(xml_bytes) # <cfdi:Comprobante ...>
emisor = root.find("cfdi:Emisor", NS)
receptor = root.find("cfdi:Receptor", NS)
tfd = root.find("cfdi:Complemento/tfd:TimbreFiscalDigital", NS)
return {
"uuid": tfd.get("UUID") if tfd is not None else None,
"rfc_emisor": emisor.get("Rfc"),
"rfc_receptor": receptor.get("Rfc"),
"total": root.get("Total"), # keep as string -> Decimal
"fecha": root.get("Fecha"),
}
Ojo: leer un atributo del XML NO es validar el CFDI. Que puedas sacar el UUID y el Total no te dice si el sello es correcto, si el certificado estaba vigente al timbrar, o si el emisor cayó en la lista 69-B. Esa validación es un tema completo por sí solo y lo cubro en cómo validar un CFDI recibido: XML, sello, vigencia y 69-B. No la reimplementes aquí: extrae los campos, y pásale el XML a ese flujo de validación.
El flujo limpio: índice de Metadata → decide → baja y valida el CFDI
Juntando todo, el orden que escala sin sorpresas es este: primero pides Metadata para tener el índice barato, lo parseas a la defensiva, decides qué UUIDs te importan, y solo entonces bajas el CFDI de ese subconjunto para validarlo. Nunca al revés.
Antes de armar la solicitud, si todavía no tienes el token, arranca por los 4 pasos de la Descarga Masiva con e.firma en Python. Y para el mapa completo de facturación en México, el hub de CFDI reúne todo.
Como siempre: soy ingeniero, no contador ni abogado. Este código te lee el paquete de forma confiable, pero las reglas del SAT, los códigos de comprobante y el formato de la Metadata cambian — verifícalos contra la fuente oficial. Las referencias que uso son la documentación del Servicio Web en sat.gob.mx, la librería phpcfdi/sat-ws-descarga-masiva y SAT-CFDI/python-satcfdi.