Cómo leer el feed Atom de la Plataforma de Contratación con Python

Tutorial para leer con Python los feeds Atom de la Plataforma de Contratación: espacios de nombres CODICE, paginación rel="next", duplicados y exportación a CSV

Por David Carrero ·

Para leer un feed Atom de la Plataforma de Contratación del Sector Público con Python basta la biblioteca estándar: zipfile para abrir el ZIP, xml.etree.ElementTree para recorrer el XML y csv para guardar el resultado. La clave está en declarar bien los cinco espacios de nombres de CODICE, seguir los enlaces rel="next" de un fichero al siguiente y quedarte con la versión más reciente de cada licitación, porque el mismo expediente aparece una vez por cada actualización. En este tutorial montamos un script de unas cien líneas que hace exactamente eso y lo probamos con ficheros reales.

Si todavía no tienes los datos, en cómo descargar los datos de la Plataforma explicamos qué conjuntos hay y cómo se organizan los ZIP.

Qué necesitas

  • Python 3.9 o superior. Lo hemos probado con 3.9 y con 3.14.
  • Nada más. No hace falta lxml ni pandas, aunque al final comentamos cuándo te compensan.
  • Un ZIP de la Plataforma. Para probar, el mensual de licitaciones de septiembre de 2026 (unos 300 MB) es buena opción:
curl -L -o licitaciones_202609.zip \
  "https://contrataciondelsectorpublico.gob.es/sindicacion/sindicacion_643/licitacionesPerfilesContratanteCompleto3_202609.zip"

Si prefieres algo más ligero para hacer pruebas, el ZIP anual de consultas preliminares de mercado de 2025 no llega a 1 MB, aunque sus entradas usan otra estructura y el script no te sacará gran cosa de ellas.

Cómo es una entrada por dentro

Antes de escribir código conviene mirar el XML. Esta es una entrada real del feed de licitaciones, recortada a lo que nos interesa:

<entry>
  <id>https://contrataciondelestado.es/sindicacion/licitacionesPerfilContratante/20680957</id>
  <link href="https://contrataciondelestado.es/wps/poc?uri=deeplink:detalle_licitacion&amp;idEvl=..."/>
  <title>Renovación De Suscripción De Licencias Ibm Spss (...)</title>
  <updated>2026-10-12T20:00:18.976+02:00</updated>
  <cac-place-ext:ContractFolderStatus>
    <cbc:ContractFolderID>CEA4423/2026</cbc:ContractFolderID>
    <cbc-place-ext:ContractFolderStatusCode>RES</cbc-place-ext:ContractFolderStatusCode>
    <cac-place-ext:LocatedContractingParty>
      <cac:Party>
        <cac:PartyIdentification><cbc:ID schemeName="NIF">S2801213F</cbc:ID></cac:PartyIdentification>
        <cac:PartyName><cbc:Name>Dirección General de Racionalización y Centralización...</cbc:Name></cac:PartyName>
      </cac:Party>
    </cac-place-ext:LocatedContractingParty>
    <cac:ProcurementProject>
      <cbc:Name>Renovación De Suscripción De Licencias Ibm Spss (...)</cbc:Name>
      <cbc:TypeCode>1</cbc:TypeCode>
      <cac:BudgetAmount>
        <cbc:TotalAmount currencyID="EUR">38720</cbc:TotalAmount>
        <cbc:TaxExclusiveAmount currencyID="EUR">32000</cbc:TaxExclusiveAmount>
      </cac:BudgetAmount>
      <cac:RequiredCommodityClassification>
        <cbc:ItemClassificationCode>48000000</cbc:ItemClassificationCode>
      </cac:RequiredCommodityClassification>
    </cac:ProcurementProject>
    <cac:TenderResult>
      <cbc:AwardDate>2026-10-09</cbc:AwardDate>
      <cac:WinningParty>
        <cac:PartyIdentification><cbc:ID schemeName="NIF">B61172219</cbc:ID></cac:PartyIdentification>
        <cac:PartyName><cbc:Name>Seidor Solutions</cbc:Name></cac:PartyName>
      </cac:WinningParty>
      <cac:AwardedTenderedProject>
        <cac:LegalMonetaryTotal>
          <cbc:TaxExclusiveAmount currencyID="EUR">28109.9</cbc:TaxExclusiveAmount>
          <cbc:PayableAmount currencyID="EUR">34012.98</cbc:PayableAmount>
        </cac:LegalMonetaryTotal>
      </cac:AwardedTenderedProject>
    </cac:TenderResult>
  </cac-place-ext:ContractFolderStatus>
</entry>

Fíjate en tres cosas. La parte Atom (id, link, title, updated) va en el espacio de nombres por defecto, el de Atom. Los datos del contrato cuelgan de ContractFolderStatus. Y el estado no va en cbc sino en cbc-place-ext, una extensión propia de la Plataforma; si lo buscas con el prefijo equivocado te devolverá vacío sin dar error.

Los espacios de nombres

Estos son los que declara la cabecera de los feeds y los que vamos a usar:

NS = {
    'atom': 'http://www.w3.org/2005/Atom',
    'cbc': 'urn:dgpe:names:draft:codice:schema:xsd:CommonBasicComponents-2',
    'cac': 'urn:dgpe:names:draft:codice:schema:xsd:CommonAggregateComponents-2',
    'cbc-place-ext': 'urn:dgpe:names:draft:codice-place-ext:schema:xsd:CommonBasicComponents-2',
    'cac-place-ext': 'urn:dgpe:names:draft:codice-place-ext:schema:xsd:CommonAggregateComponents-2',
}

Los prefijos de la izquierda los eliges tú; lo que cuenta es la URI. Si en algún sitio ves cac-ext en lugar de cac-place-ext, es lo mismo con otro nombre.

Extraer los campos de una entrada

Con find y rutas relativas sacamos cada dato. Dos funciones auxiliares evitan que el script reviente cuando falta un elemento, cosa que pasa a menudo (una licitación en plazo no tiene adjudicatario, por ejemplo):

def text(node, path):
    """Devuelve el texto del primer elemento que casa con path, o cadena vacía."""
    if node is None:
        return ''
    found = node.find(path, NS)
    return (found.text or '').strip() if found is not None else ''


def party_id(party, scheme):
    """Busca un identificador (NIF, DIR3...) dentro de un cac:Party."""
    if party is None:
        return ''
    for pid in party.findall('cac:PartyIdentification/cbc:ID', NS):
        if pid.get('schemeName') == scheme:
            return (pid.text or '').strip()
    return ''

Los códigos que vas a ver en estado son PRE (anuncio previo), PUB (en plazo), EV (pendiente de adjudicación), ADJ (adjudicada), RES (resuelta) y ANUL (anulada). En tipo, 1 es suministros, 2 servicios y 3 obras; hay más códigos para concesiones y otros tipos, todos descritos en la especificación oficial.

Un aviso sobre los lotes: cuando un contrato tiene varios, hay un TenderResult por lote. El script se queda con el primero para mantener una fila por licitación. Si necesitas el detalle por lote, recorre cfs.findall('cac:TenderResult', NS) y genera una fila por cada uno.

Seguir la paginación

Cada fichero .atom trae como mucho 500 entradas y enlaza con el siguiente mediante <link rel="next">. Dentro de un ZIP esos enlaces son nombres de fichero relativos, así que basta con quedarse con la última parte de la ruta y buscarla en el ZIP. Se empieza por el fichero que se llama igual que el feed y se para cuando el siguiente ya no está en el paquete (el último fichero apunta al ZIP anterior).

Si lees el feed vivo en lugar del ZIP, los enlaces son URL completas y el bucle es el mismo con urllib.request.urlopen(url) en vez de zf.open(nombre). Pon siempre un límite de páginas, porque la cadena retrocede hasta el principio del histórico.

Quedarse con la última versión

Cada vez que se publica algo nuevo de un expediente, la Plataforma añade una entrada completa sin quitar la anterior. Por eso agrupamos por el <id> de la entrada y nos quedamos con la de <updated> más reciente. Comparamos fechas de verdad, no cadenas, porque unas llevan +01:00 y otras +02:00 según la época del año.

La diferencia no es pequeña. En el ZIP de plataformas agregadas de septiembre de 2026 hay 34.501 entradas que se quedan en 15.403 licitaciones al deduplicar.

El script completo

import csv
import zipfile
import xml.etree.ElementTree as ET
from datetime import datetime

ZIP_PATH = 'licitaciones_202609.zip'
FIRST_FILE = 'licitacionesPerfilesContratanteCompleto3.atom'
OUT_CSV = 'licitaciones_202609.csv'

NS = {
    'atom': 'http://www.w3.org/2005/Atom',
    'cbc': 'urn:dgpe:names:draft:codice:schema:xsd:CommonBasicComponents-2',
    'cac': 'urn:dgpe:names:draft:codice:schema:xsd:CommonAggregateComponents-2',
    'cbc-place-ext': 'urn:dgpe:names:draft:codice-place-ext:schema:xsd:CommonBasicComponents-2',
    'cac-place-ext': 'urn:dgpe:names:draft:codice-place-ext:schema:xsd:CommonAggregateComponents-2',
}


def text(node, path):
    """Devuelve el texto del primer elemento que casa con path, o cadena vacía."""
    if node is None:
        return ''
    found = node.find(path, NS)
    return (found.text or '').strip() if found is not None else ''


def party_id(party, scheme):
    """Busca un identificador (NIF, DIR3...) dentro de un cac:Party."""
    if party is None:
        return ''
    for pid in party.findall('cac:PartyIdentification/cbc:ID', NS):
        if pid.get('schemeName') == scheme:
            return (pid.text or '').strip()
    return ''


def parse_entry(entry):
    cfs = entry.find('cac-place-ext:ContractFolderStatus', NS)
    if cfs is None:
        return None

    party = cfs.find('cac-place-ext:LocatedContractingParty/cac:Party', NS)
    project = cfs.find('cac:ProcurementProject', NS)
    # Primer resultado; en contratos con lotes hay uno por lote
    result = cfs.find('cac:TenderResult', NS)
    winner = result.find('cac:WinningParty', NS) if result is not None else None
    link = entry.find('atom:link', NS)

    return {
        'id': text(entry, 'atom:id'),
        'updated': text(entry, 'atom:updated'),
        'url': link.get('href', '') if link is not None else '',
        'expediente': text(cfs, 'cbc:ContractFolderID'),
        'estado': text(cfs, 'cbc-place-ext:ContractFolderStatusCode'),
        'organo': text(party, 'cac:PartyName/cbc:Name'),
        'organo_nif': party_id(party, 'NIF'),
        'objeto': text(project, 'cbc:Name'),
        'tipo': text(project, 'cbc:TypeCode'),
        'importe_sin_iva': text(project, 'cac:BudgetAmount/cbc:TaxExclusiveAmount'),
        'cpv': text(project, 'cac:RequiredCommodityClassification/cbc:ItemClassificationCode'),
        'nuts': text(project, 'cac:RealizedLocation/cbc:CountrySubentityCode'),
        'adjudicatario': text(winner, 'cac:PartyName/cbc:Name'),
        'adjudicatario_nif': party_id(winner, 'NIF'),
        'importe_adjudicacion_sin_iva': text(
            result, 'cac:AwardedTenderedProject/cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount'),
        'fecha_adjudicacion': text(result, 'cbc:AwardDate'),
    }


def is_newer(a, b):
    """Compara dos fechas <updated>, que llevan zona horaria."""
    return datetime.fromisoformat(a['updated']) > datetime.fromisoformat(b['updated'])


def read_zip(zip_path, first_file):
    """Recorre los .atom del ZIP siguiendo los enlaces rel="next"."""
    rows = {}
    with zipfile.ZipFile(zip_path) as zf:
        names = set(zf.namelist())
        current = first_file
        visited = set()
        while current in names and current not in visited:
            visited.add(current)
            with zf.open(current) as fh:
                feed = ET.parse(fh).getroot()

            for entry in feed.findall('atom:entry', NS):
                row = parse_entry(entry)
                if row is None:
                    continue
                # Guardamos solo la versión más reciente de cada licitación
                previous = rows.get(row['id'])
                if previous is None or is_newer(row, previous):
                    rows[row['id']] = row

            next_link = feed.find("atom:link[@rel='next']", NS)
            # Dentro del ZIP los enlaces son nombres de fichero relativos
            current = next_link.get('href').rsplit('/', 1)[-1] if next_link is not None else None
    return list(rows.values())


rows = read_zip(ZIP_PATH, FIRST_FILE)
with open(OUT_CSV, 'w', newline='', encoding='utf-8') as fh:
    writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
    writer.writeheader()
    writer.writerows(rows)
print(f'{len(rows)} licitaciones guardadas en {OUT_CSV}')

Para usarlo con otro conjunto cambia ZIP_PATH y FIRST_FILE. Con el ZIP de agregadas, por ejemplo, el primer fichero es PlataformasAgregadasSinMenores.atom. Funciona igual con licitaciones, contratos menores y agregadas, porque comparten estructura; los encargos a medios propios también cuelgan de ContractFolderStatus pero con campos propios, y las consultas preliminares usan otro elemento raíz (PreliminaryMarketConsultationStatus), así que para esos dos conviene repasar la especificación y adaptar parse_entry.

Con el ZIP de agregadas de septiembre (unos 390 MB de XML descomprimido) el script tarda unos pocos segundos en un portátil normal. ElementTree carga cada fichero .atom entero en memoria, pero como ninguno pasa de unos pocos megas no da problemas.

Cuándo pasar a lxml o pandas

Para procesar un mes o un año, la biblioteca estándar va sobrada. Si vas a cargar toda la serie desde 2012, lxml acelera el análisis del XML y su API es casi idéntica: cambiar el import por from lxml import etree as ET suele bastar para este script. Y si lo que quieres es analizar el CSV resultante, pandas.read_csv('licitaciones_202609.csv', dtype=str) te lo deja listo; lee todo como texto y convierte tú los importes, para que no se pierdan ceros a la izquierda en NIF ni en códigos CPV.

Si prefieres no escribir el procesado desde cero, en Código tienes proyectos de código abierto que ya cargan estos feeds en una base de datos.

Preguntas frecuentes

¿Por qué me salen menos filas que entradas? Porque el script deduplica. Una licitación con anuncio, adjudicación y formalización aparece al menos tres veces en el feed, y solo nos interesa la última versión.

¿Qué hago con los at:deleted-entry? Marcan expedientes que han dejado de estar disponibles en la Plataforma, anulados o archivados. El script no los lee; si mantienes una base de datos actualizada a diario, conviene procesarlos para marcar esos expedientes.

¿Los importes vienen con IVA? Hay de los dos. TaxExclusiveAmount es sin impuestos y TotalAmount (licitación) o PayableAmount (adjudicación) con impuestos. Usa siempre el mismo criterio al comparar.

¿Puedo leer el feed directamente desde internet sin bajar el ZIP? Sí. El feed vivo tiene una URL fija por conjunto y urllib lo abre sin problemas. Es lo práctico para actualizaciones diarias; para cargar el histórico, el ZIP es mucho más rápido.