AUTOMATIZACIÓN · 5 MIN DE LECTURA
Cómo convertir facturas XML (CFDI 4.0) a Excel con Python
Una factura electrónica mexicana es un archivo XML con una estructura fija que define el SAT. Con Python puedes leer una carpeta completa de XML, sacar de cada uno el UUID, los RFC, el subtotal, los impuestos y el total, y guardar todo en un Excel con formato. Abajo está el script completo, probado con CFDI 3.3 y 4.0.
Respuesta corta: cómo pasar facturas XML a Excel con Python
El XML de un CFDI es texto con etiquetas. Python lo lee con su módulo estándar xml.etree.ElementTree, saca los datos de cada factura (emisor, receptor, importes, impuestos y el UUID del timbre) y pandas los convierte en una tabla que se guarda como Excel con openpyxl.
El script de esta guía hace eso con una carpeta completa: une las facturas en una sola hoja, descarta las que bajaste dos veces, ignora los XML que no son facturas y da formato de moneda y fecha. Lo probé con facturas ficticias de las versiones 3.3 y 4.0 que primero validé contra los esquemas oficiales del SAT (cfdv40.xsd y TimbreFiscalDigitalv11.xsd).
¿Qué hay dentro de un XML de CFDI?
Este es un CFDI 4.0 de prueba, recortado para que se vea la estructura (los puntos suspensivos ocultan sellos y atributos que el script no usa):
<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4" Version="4.0"
Serie="A" Folio="101" Fecha="2026-09-03T10:15:00" SubTotal="1000.00"
Moneda="MXN" Total="1160.00" TipoDeComprobante="I" ...>
<cfdi:Emisor Rfc="EKU9003173C9" Nombre="ESCUELA KEMPER URGATE" RegimenFiscal="601"/>
<cfdi:Receptor Rfc="XEXX010101000" Nombre="CLIENTE DE PRUEBA SA DE CV"
DomicilioFiscalReceptor="80000" RegimenFiscalReceptor="601" UsoCFDI="G03"/>
<cfdi:Conceptos>
<cfdi:Concepto Descripcion="Servicio de prueba" Importe="1000.00" ...>
<cfdi:Impuestos>...</cfdi:Impuestos>
</cfdi:Concepto>
</cfdi:Conceptos>
<cfdi:Impuestos TotalImpuestosTrasladados="160.00">
<cfdi:Traslados>
<cfdi:Traslado Base="1000.00" Impuesto="002" TipoFactor="Tasa"
TasaOCuota="0.160000" Importe="160.00"/>
</cfdi:Traslados>
</cfdi:Impuestos>
<cfdi:Complemento>
<tfd:TimbreFiscalDigital xmlns:tfd="http://www.sat.gob.mx/TimbreFiscalDigital"
UUID="5F2C1A3B-0000-4000-8000-000000000101" FechaTimbrado="2026-09-03T10:15:00" .../>
</cfdi:Complemento>
</cfdi:Comprobante>Comprobante(la raíz): versión, serie, folio, fecha, subtotal, descuento, moneda, total y tipo de comprobante.EmisoryReceptor: RFC, nombre y régimen fiscal; el receptor también trae el uso del CFDI.Impuestosdirecto bajoComprobante: el resumen de impuestos de toda la factura. CadaConceptotrae además sus propios impuestos.Complemento/TimbreFiscalDigital: el UUID (el folio fiscal) y la fecha de timbrado.- Todo vive dentro de un namespace:
http://www.sat.gob.mx/cfd/4en la versión 4.0 yhttp://www.sat.gob.mx/cfd/3en la 3.3. Si buscasEmisorsin namespace, Python no encuentra nada.
Qué necesitas instalar
Python 3.10 o más reciente y dos librerías. El lector de XML ya viene con Python.
pip install pandas openpyxlPaso 1: leer una factura y sacar sus datos
La función leer_cfdi abre un XML, revisa que sea un CFDI 3.3 o 4.0 y devuelve un diccionario con los datos que irán en cada fila del Excel.
"""Lee una carpeta de facturas XML (CFDI 3.3 y 4.0) y genera un Excel con una fila por factura."""
import sys
import xml.etree.ElementTree as ET
from decimal import Decimal
from pathlib import Path
import pandas as pd
ESPACIOS = {"3.3": "http://www.sat.gob.mx/cfd/3", "4.0": "http://www.sat.gob.mx/cfd/4"}
TIMBRE = "http://www.sat.gob.mx/TimbreFiscalDigital"
IMPUESTOS = {"001": "ISR", "002": "IVA", "003": "IEPS"}
def dinero(valor):
"""Los importes se suman con Decimal para no arrastrar errores de centavos."""
return Decimal(valor or "0")
def leer_cfdi(ruta):
raiz = ET.parse(ruta).getroot()
version = raiz.get("Version")
espacio = ESPACIOS.get(version)
if espacio is None or raiz.tag != f"{{{espacio}}}Comprobante":
raise ValueError("no es un CFDI 3.3 ni 4.0")
ns = {"cfdi": espacio, "tfd": TIMBRE}
emisor = raiz.find("cfdi:Emisor", ns)
receptor = raiz.find("cfdi:Receptor", ns)
timbre = raiz.find("cfdi:Complemento/tfd:TimbreFiscalDigital", ns)
# Solo los impuestos del comprobante (hijos directos), no los de cada concepto: si no, se cuentan dos veces.
trasladados = {nombre: Decimal("0") for nombre in IMPUESTOS.values()}
retenidos = {nombre: Decimal("0") for nombre in IMPUESTOS.values()}
for traslado in raiz.findall("cfdi:Impuestos/cfdi:Traslados/cfdi:Traslado", ns):
trasladados[IMPUESTOS[traslado.get("Impuesto")]] += dinero(traslado.get("Importe")) # Los exentos no traen Importe.
for retencion in raiz.findall("cfdi:Impuestos/cfdi:Retenciones/cfdi:Retencion", ns):
retenidos[IMPUESTOS[retencion.get("Impuesto")]] += dinero(retencion.get("Importe"))
return {
"Archivo": ruta.name,
"Versión": version,
"UUID": timbre.get("UUID").upper() if timbre is not None else "",
"Fecha": raiz.get("Fecha"),
"Serie": raiz.get("Serie", ""),
"Folio": raiz.get("Folio", ""),
"Tipo": raiz.get("TipoDeComprobante"),
"RFC emisor": emisor.get("Rfc"),
"Emisor": emisor.get("Nombre", ""),
"RFC receptor": receptor.get("Rfc"),
"Receptor": receptor.get("Nombre", ""),
"Uso CFDI": receptor.get("UsoCFDI"),
"Moneda": raiz.get("Moneda"),
"Subtotal": dinero(raiz.get("SubTotal")),
"Descuento": dinero(raiz.get("Descuento")),
"IVA trasladado": trasladados["IVA"],
"IEPS trasladado": trasladados["IEPS"],
"ISR retenido": retenidos["ISR"],
"IVA retenido": retenidos["IVA"],
"Total": dinero(raiz.get("Total")),
}- El namespace se elige según el atributo
Version, así el mismo código sirve para facturas viejas y nuevas. - Los importes se suman con
Decimaly no confloat, para que 0.1 + 0.2 no termine en 0.30000000000000004 cuando sumas cientos de facturas. - Los impuestos se leen solo del nodo
Impuestosque cuelga directo del comprobante. Si usas una búsqueda que recorra todo el árbol, sumas también los impuestos de cada concepto y el IVA te sale al doble. - Un traslado exento no trae
Importe; por esodinero()convierte un valor vacío en cero.
Paso 2: recorrer la carpeta y armar la tabla
La función main aplica leer_cfdi a cada XML de la carpeta. Lo que no es un CFDI (un acuse, un XML dañado) no detiene el proceso: se anota y se reporta al final.
def main(carpeta, salida):
filas, omitidos = [], []
for ruta in sorted(Path(carpeta).glob("*.xml")):
try:
filas.append(leer_cfdi(ruta))
except (ET.ParseError, ValueError, KeyError) as error:
omitidos.append(f"{ruta.name}: {error}")
if not filas:
print(f"No encontré facturas en {carpeta}")
return
facturas = pd.DataFrame(filas)
antes = len(facturas)
facturas = facturas.drop_duplicates(subset="UUID") # La misma factura bajada dos veces tiene el mismo UUID.
facturas["Fecha"] = pd.to_datetime(facturas["Fecha"])
facturas = facturas.sort_values("Fecha")
columnas_dinero = ["Subtotal", "Descuento", "IVA trasladado", "IEPS trasladado", "ISR retenido", "IVA retenido", "Total"]
for columna in columnas_dinero:
facturas[columna] = facturas[columna].astype(float) # Excel guarda números, no Decimal.drop_duplicates(subset="UUID")quita las facturas repetidas. Pasa seguido: la misma factura llega por correo y también la bajas del portal del proveedor, con otro nombre de archivo.- La fecha se convierte a fecha real para que puedas ordenar y filtrar en Excel.
Paso 3: guardar el Excel con formato
La última parte escribe la hoja y le da lo mínimo para trabajarla cómoda: encabezados fijos, filtros, moneda, fechas legibles y columnas con ancho suficiente.
with pd.ExcelWriter(salida, engine="openpyxl") as libro:
facturas.to_excel(libro, sheet_name="Facturas", index=False)
hoja = libro.sheets["Facturas"]
hoja.freeze_panes = "A2"
hoja.auto_filter.ref = hoja.dimensions
for celda in hoja[1]:
hoja.column_dimensions[celda.column_letter].width = max(12, len(str(celda.value)) + 4)
if celda.value in columnas_dinero:
for (valor,) in hoja.iter_rows(min_row=2, min_col=celda.column, max_col=celda.column):
valor.number_format = '"$"#,##0.00'
if celda.value == "Fecha":
for (valor,) in hoja.iter_rows(min_row=2, min_col=celda.column, max_col=celda.column):
valor.number_format = "dd/mm/yyyy hh:mm"
hoja.column_dimensions["C"].width = 40 # UUID
print(f"{len(facturas)} facturas guardadas en {salida}; UUID repetidos descartados: {antes - len(facturas)}")
for aviso in omitidos:
print("Omitido:", aviso)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "facturas", sys.argv[2] if len(sys.argv) > 2 else "facturas.xlsx")Cómo usarlo y qué resultado da
Guarda las tres partes, en orden, en un archivo cfdi_a_excel.py, pon tus XML en una carpeta y ejecuta el script indicando la carpeta y el nombre del Excel. Esta es la salida real con mi carpeta de prueba: dos CFDI 4.0, un 3.3, una factura repetida y un acuse que no es factura.
python cfdi_a_excel.py facturas facturas.xlsx
3 facturas guardadas en facturas.xlsx; UUID repetidos descartados: 1
Omitido: acuse.xml: no es un CFDI 3.3 ni 4.0- La factura de honorarios de prueba (subtotal 5,000, IVA 800, ISR retenido 500 e IVA retenido 533.33) aparece con total 4,766.67, igual que en el XML.
- Si la carpeta no tiene XML, el script lo dice y no crea un Excel vacío.
Notas de crédito, pagos y nómina: cuidado al sumar
La columna Tipo dice qué clase de comprobante es cada XML, y no todos se suman igual:
I(ingreso): la factura normal.E(egreso): notas de crédito y devoluciones. Restan, no suman: en Excel filtra por tipo antes de sacar totales.P(pago): el comprobante con complemento de pagos lleva subtotal y total en 0 y monedaXXX. Los montos pagados están en el complemento, que este script no lee.N(nómina): los datos de percepciones y deducciones están en el complemento de nómina.T(traslado): ampara el movimiento de mercancía, no una venta.
Qué no hace este script
Conviene tenerlo claro antes de usarlo para la contabilidad:
- No comprueba que la factura sea auténtica ni que siga vigente. Para eso está el verificador de CFDI del SAT, que consulta por UUID.
- No valida el sello digital ni la cadena original.
- No descarga las facturas; parte de que ya tienes los XML en una carpeta.
- Hace una fila por factura. Si necesitas una fila por concepto, recorre
cfdi:Conceptos/cfdi:Conceptodentro deleer_cfdiy devuelve una lista de filas en lugar de una sola.
CFDI 3.3 y 4.0: qué cambia para el script
Para leer los datos de esta guía solo cambia el namespace, y el script ya lo resuelve con el atributo Version. La versión 4.0 agregó datos que la 3.3 no tenía, como el código postal y el régimen fiscal del receptor (DomicilioFiscalReceptor y RegimenFiscalReceptor), la clave de exportación y el objeto de impuesto de cada concepto. Si quieres esas columnas, agrégalas en leer_cfdi; en las facturas 3.3 quedarán vacías.
Cómo lo probé
No usé facturas reales: los XML de un negocio llevan RFC, nombres y montos que no deben circular. Generé facturas ficticias con el RFC de pruebas del SAT y RFC genéricos, y antes de probar el script las validé con lxml contra los esquemas oficiales cfdv40.xsd y TimbreFiscalDigitalv11.xsd. El juego de prueba cubre los casos que más fallan en la vida real: una factura con retenciones, una repetida con otro nombre de archivo, una versión 3.3 y un XML que no es factura.
Es la misma lógica que uso cuando automatizo reportes para un negocio: leer la fuente oficial tal como viene, no confiar en que todos los archivos estén bien y dejar a la vista lo que se omitió.