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.
  • Emisor y Receptor: RFC, nombre y régimen fiscal; el receptor también trae el uso del CFDI.
  • Impuestos directo bajo Comprobante: el resumen de impuestos de toda la factura. Cada Concepto trae 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/4 en la versión 4.0 y http://www.sat.gob.mx/cfd/3 en la 3.3. Si buscas Emisor sin 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 openpyxl

Paso 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 Decimal y no con float, para que 0.1 + 0.2 no termine en 0.30000000000000004 cuando sumas cientos de facturas.
  • Los impuestos se leen solo del nodo Impuestos que 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 eso dinero() 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 moneda XXX. 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:Concepto dentro de leer_cfdi y 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ó.

Preguntas frecuentes

¿El script sirve para facturas de nómina?

Lee los datos generales de cualquier CFDI, incluidos los de nómina: emisor, receptor, fecha, UUID y totales. Las percepciones y deducciones están en el complemento de nómina, que este script no lee; habría que agregar esa parte.

¿Puedo sacar una fila por cada concepto de la factura?

Sí. Dentro de leer_cfdi, recorre los nodos cfdi:Conceptos/cfdi:Concepto y arma una fila por concepto con su descripción, cantidad, valor unitario e importe, repitiendo en cada fila el UUID y los RFC de la factura.

¿Necesito saber programar para usarlo?

Necesitas instalar Python y ejecutar un comando, nada más. Si lo vas a usar cada mes, conviene programarlo para que corra solo, igual que se explica en la guía de reportes de Excel con Python.

¿Por qué no usar float para los importes?

Porque float representa los decimales de forma aproximada y, al sumar cientos de importes, aparecen diferencias de centavos. Decimal guarda el valor exacto que trae el XML.

FUENTES
SIGUIENTE PASO

Si quieres llevarlo a la práctica

¿Lo platicamos?

Si esta guía te dejó dudas sobre tu caso concreto, escríbeme. Te respondo personalmente.