El módulo 5 terminó con la confesión más dolorosa de Ana: cada noche, al apagar el ordenador de Papyrus, el catálogo entero — objetos Libro con sus validaciones, los socios, las líneas de venta — se evapora. La RAM es rápida pero olvidadiza: cuando el programa termina, sus variables mueren con él. La solución es el disco: escribir los datos en archivos que sobreviven a los reinicios y leerlos al arrancar. En esta lección aprenderás open() y sus modos, la forma correcta de trabajar con archivos (with), las distintas maneras de leer y escribir texto, y por qué encoding="utf-8" debe acompañarte siempre. De paso saldaremos una deuda que arrastra el curso desde el módulo 1: ficha_libro.py por fin guardará las altas en un archivo.

Contenido

  1. Persistencia: por qué la memoria no basta
  2. open(): la puerta al disco
  3. Los modos de apertura: r, w, a, x
  4. with: la forma correcta de abrir archivos
  5. Escribir: write() y writelines()
  6. Leer: read(), readline(), readlines() e iterar línea a línea
  7. encoding="utf-8": siempre y explícito
  8. El salto de línea: \n al escribir, strip() al leer
  9. Promesa saldada: ficha_libro.py escribe en registro_altas.txt
  10. Errores comunes y consejos
  11. Ejercicios con soluciones

Persistencia: por qué la memoria no basta

Todo lo que has creado hasta ahora vive en la memoria RAM del proceso de Python: existe mientras el programa se ejecuta y desaparece al terminar. A esa propiedad de los datos de sobrevivir entre ejecuciones se la llama persistencia, y la forma más universal de conseguirla es el sistema de archivos: texto plano hoy, CSV en 06-02, JSON en 06-03.

flowchart LR
    A["RAM<br>catalogo: dict[str, Libro]<br>rápida, volátil"] -- "escribir (esta lección)" --> B["Disco<br>catalogo.txt, registro_altas.txt<br>lento, persistente"]
    B -- "leer (esta lección)" --> A

El plan del módulo es exactamente ese ciclo: guardar al cerrar, cargar al abrir. Papyrus dejará de amanecer amnésica.

open(): la puerta al disco

La función integrada open() conecta tu programa con un archivo del disco y devuelve un objeto archivo con métodos para leer y escribir:

archivo = open("saludo.txt", "w", encoding="utf-8")  # abre (creándolo) para escribir
archivo.write("Bienvenidos a Papyrus\n")             # escribe una cadena
archivo.close()                                      # ¡imprescindible! libera el archivo

Los tres argumentos que usarás siempre:

  • Ruta: "saludo.txt" es una ruta relativa — se resuelve desde la carpeta en que ejecutas el programa. Las rutas a fondo (absolutas, pathlib) llegan en 06-04; de momento trabaja con archivos junto a tu script.
  • Modo: "w", "r", "a"... — la siguiente sección.
  • encoding: cómo se traducen los caracteres a bytes. Siempre "utf-8", y en su sección verás por qué no es negociable.

close() es crítico: hasta que no cierras, lo escrito puede quedarse en un búfer sin llegar al disco, y el archivo queda bloqueado para otros programas. Y ahí está el problema: si entre open() y close() algo falla, el close() no llega a ejecutarse. Por eso casi nunca escribirás open()/close() a mano — enseguida llega with.

Los modos de apertura: r, w, a, x

El segundo argumento de open() decide qué puedes hacer con el archivo y qué pasa si existe o no:

Modo Significado Si el archivo existe Si NO existe Uso típico en Papyrus
"r" read — leer (es el modo por defecto) Lo abre para leer Error (FileNotFoundError) Cargar el catálogo al arrancar
"w" write — escribir Lo vacía por completo y escribe desde cero Lo crea Volcar el catálogo entero al cerrar
"a" append — añadir Escribe al final, conservando lo anterior Lo crea Registro de altas: cada alta, una línea nueva
"x" exclusive — crear en exclusiva Error (FileExistsError) Lo crea Evitar machacar un archivo por accidente

Dos advertencias antes de continuar:

  • "w" es destructivo: en cuanto ejecutas open("catalogo.txt", "w", ...), el contenido anterior ya se ha perdido — aunque luego no escribas nada. Elegir "w" donde tocaba "a" es el error más caro de esta lección.
  • Abrir con "r" un archivo que no existe lanza FileNotFoundError. El manejo elegante de errores (try/except) es el tema central del módulo 7; hasta entonces usaremos un paliativo explícito — comprobar antes con Path.exists():
from pathlib import Path   # pathlib protagonizará 06-04; hoy solo le pedimos exists()

if Path("catalogo.txt").exists():
    ...   # seguro leerlo
else:
    print("Aún no hay catálogo guardado; empezamos de cero.")

Que quede dicho con claridad: esta comprobación previa es un apaño temporal y tiene grietas (el archivo podría desaparecer entre la comprobación y la lectura). La herramienta correcta son las excepciones, y las aprenderás en el módulo 7.

Existen también los modos binarios ("rb", "wb") para bytes en crudo — imágenes, o el pickle que mencionaremos en 06-03 —, pero en este módulo trabajamos con texto.

with: la forma correcta de abrir archivos

La versión profesional del primer ejemplo no lleva close():

with open("saludo.txt", "w", encoding="utf-8") as archivo:
    archivo.write("Bienvenidos a Papyrus\n")
# al salir del bloque, el archivo YA está cerrado — pase lo que pase dentro

with garantiza el cierre siempre: si el bloque termina bien, cierra; si dentro salta un error a medio camino, cierra igualmente antes de propagar el error. Es el equivalente a un close() imposible de olvidar. La variable tras as (archivo) es el mismo objeto archivo de antes, solo que con el ciclo de vida gestionado.

with funciona con archivos porque estos implementan el protocolo de los context managers — un mecanismo general de Python que también sirve para conexiones, bloqueos y recursos propios. Ese protocolo completo (y cómo escribir los tuyos) se estudia en el módulo 8; en este módulo te basta la regla práctica: todo open() va dentro de un with. Así lo haremos en el resto del curso sin excepción.

Escribir: write() y writelines()

write(cadena) escribe exactamente la cadena que le pasas — ni más ni menos. Dos consecuencias inmediatas:

  • No añade salto de línea: si quieres líneas, escribe tú el "\n".
  • Solo acepta cadenas: los números hay que convertirlos (las f-strings del módulo 1 son perfectas para esto).
libros = [("La Odisea", 12.50, 4), ("Hamlet", 9.95, 6), ("El Quijote", 15.90, 8), ("Fausto", 21.00, 0)]

with open("catalogo.txt", "w", encoding="utf-8") as f:
    f.write("Catálogo de Papyrus\n")            # el \n lo pones tú
    for titulo, precio, stock in libros:
        f.write(f"{titulo} | {precio:.2f} | {stock}\n")   # números → texto vía f-string

Contenido resultante de catalogo.txt:

Catálogo de Papyrus
La Odisea | 12.50 | 4
Hamlet | 9.95 | 6
El Quijote | 15.90 | 8
Fausto | 21.00 | 0

writelines(lista) escribe todas las cadenas de una lista... y, pese a su nombre, tampoco añade saltos de línea. Es útil cuando ya tienes las líneas preparadas:

lineas = [f"{t} | {p:.2f} | {s}\n" for t, p, s in libros]   # comprensión (02-04), \n incluido
with open("catalogo.txt", "w", encoding="utf-8") as f:
    f.write("Catálogo de Papyrus\n")
    f.writelines(lineas)

Fíjate en el separador |: lo hemos inventado nosotros, y funciona… hasta que un título contenga una barra vertical. Inventar formatos con separadores caseros es frágil — exactamente el problema que el formato CSV y su módulo estándar resuelven en 06-02.

Leer: read(), readline(), readlines() e iterar línea a línea

Para leer tienes cuatro caminos; elegir bien depende del tamaño del archivo:

Método Devuelve Memoria Cuándo usarlo
f.read() TODO el contenido en una sola cadena Todo el archivo a la vez Archivos pequeños que procesas como bloque
f.readline() La siguiente línea (con su \n) Una línea Leer una cabecera y parar
f.readlines() Lista con todas las líneas (con \n) Todo el archivo a la vez Necesitas la lista completa (contar, indexar)
for linea in f: Una línea por vuelta Una línea cada vez El modo por defecto: procesar línea a línea
with open("catalogo.txt", "r", encoding="utf-8") as f:
    contenido = f.read()          # una única cadena con \n incluidos
print(len(contenido))

Para un catalogo.txt de cinco líneas cualquier método vale. Pero imagina el histórico de ventas de diez años de Papyrus: read() y readlines() lo cargarían entero en RAM. Iterar sobre el archivo, en cambio, lee una línea, la procesa, la olvida y pasa a la siguiente — memoria constante, da igual que el archivo tenga cinco líneas o cinco millones:

with open("catalogo.txt", "r", encoding="utf-8") as f:
    next(f)                                   # salta la cabecera "Catálogo de Papyrus"
    for linea in f:                           # el objeto archivo es iterable (02-02)
        titulo, precio, stock = linea.strip().split(" | ")   # 04-05: strip + split
        print(f"{titulo}: {float(precio):.2f} € — stock {int(stock)}")

Observa las conversiones float(precio) e int(stock): un archivo de texto solo contiene texto. El 12.50 que escribiste como número vuelve como la cadena "12.50", y recuperar los tipos es responsabilidad tuya. Este "peaje de conversión" reaparecerá en CSV (06-02) y desaparecerá casi del todo con JSON (06-03).

encoding="utf-8": siempre y explícito

Un archivo de texto es, en el fondo, una secuencia de bytes, y el encoding es el diccionario que traduce caracteres ↔ bytes. Con las letras ASCII (az) todos los diccionarios coinciden; el problema son las tildes, la ñ, , ·… Si no pasas encoding, Python usa el de la plataforma: UTF-8 en macOS/Linux, pero históricamente cp1252 en Windows. El mismo programa que en tu portátil escribe Antología puede leer Antología (o lanzar UnicodeDecodeError) en el ordenador de la tienda.

# MAL: encoding implícito — depende de la máquina que ejecute
with open("catalogo.txt", "w") as f:
    f.write("Antología de García Lorca — 14 €\n")

# BIEN: explícito — el archivo es idéntico en Windows, macOS y Linux
with open("catalogo.txt", "w", encoding="utf-8") as f:
    f.write("Antología de García Lorca — 14 €\n")

Regla del curso, sin excepciones: todo open() de texto lleva encoding="utf-8", al escribir y al leer (con el mismo diccionario en ambas direcciones, claro). UTF-8 es el estándar de facto de la web y de casi todo formato moderno. Una librería con ñ en la mitad de sus fichas no puede permitirse otra cosa.

El salto de línea: \n al escribir, strip() al leer

El carácter \n que separas las líneas al escribir viaja dentro de cada línea al leer: readline(), readlines() y la iteración lo conservan al final de la cadena. Si lo olvidas, aparecen comparaciones que fallan y prints con líneas en blanco fantasma:

with open("catalogo.txt", "r", encoding="utf-8") as f:
    primera = f.readline()

print(repr(primera))                    # 'Catálogo de Papyrus\n'  ← ahí está
print(primera == "Catálogo de Papyrus")            # False (por culpa del \n)
print(primera.strip() == "Catálogo de Papyrus")    # True

El antídoto es viejo conocido: strip() (04-05) al procesar cada línea leída — elimina el \n final y de regalo los espacios accidentales. El patrón canónico de lectura queda así: for linea in f: + linea.strip() como primer paso. (Python además normaliza automáticamente los saltos de Windows \r\n a \n en modo texto, así que strip() te cubre también ese caso.)

Promesa saldada: ficha_libro.py escribe en registro_altas.txt

En el módulo 1, ficha_libro.py pedía los datos de un libro con input() y mostraba la ficha con print() — y prometimos que algún día esa ficha no moriría al cerrar la ventana. Ese día es hoy. La versión definitiva da de alta el libro y deja constancia en disco, usando el modo "a" para que cada ejecución añada una línea sin borrar las anteriores:

"""ficha_libro.py — alta de libros con registro persistente (promesa del módulo 1)."""
from dataclasses import dataclass
from datetime import date          # biblioteca estándar, 03-05

NOMBRE_TIENDA = "Papyrus"
REGISTRO = "registro_altas.txt"

@dataclass
class Libro:                       # la dataclass canónica de 05-06 (versión mínima)
    titulo: str
    precio: float
    stock: int = 0

    def __post_init__(self):
        self.titulo = self.titulo.strip()
        if not self.titulo:
            raise ValueError("El título no puede estar vacío")
        if self.precio < 0:
            raise ValueError(f"Precio negativo: {self.precio}")

def registrar_alta(libro):
    """Añade una línea al registro de altas. Modo 'a': nunca borra lo anterior."""
    with open(REGISTRO, "a", encoding="utf-8") as f:
        f.write(f"{date.today()} | ALTA | {libro.titulo} | {libro.precio:.2f} | {libro.stock}\n")

if __name__ == "__main__":         # 03-04: ejecutable e importable
    titulo = input("Título: ")
    precio = float(input("Precio (€): "))
    stock = int(input("Stock: "))
    libro = Libro(titulo, precio, stock)
    registrar_alta(libro)
    print(f"{libro.titulo} dado de alta en {NOMBRE_TIENDA} y registrado en {REGISTRO}.")

Tras dar de alta dos libros en días distintos, registro_altas.txt acumula la historia:

2026-07-12 | ALTA | Poeta en Nueva York | 13.40 | 5
2026-07-13 | ALTA | La Odisea | 12.50 | 4

Cada pieza viene de un módulo anterior: input()/f-strings (M1), la dataclass validando al nacer (05-06), date.today() (03-05), y lo nuevo — with + "a" + encoding="utf-8" — pone la persistencia. Ana ya tiene algo que el apagón nocturno no puede borrar.

Errores Comunes y Consejos

  • Abrir con "w" cuando querías "a": el archivo se vacía en el instante del open(). Si es un registro histórico (altas, ventas), es "a"; "w" es para volcados completos que se regeneran enteros.
  • Olvidar encoding="utf-8": funciona en tu máquina y revienta (o corrompe tildes) en otra. Explícito siempre, en lectura y escritura.
  • Esperar que write() o writelines() añadan \n: no lo hacen. Tú pones los saltos; el nombre writelines engaña.
  • Olvidar strip() al leer: el \n invisible hace fallar comparaciones y split(). Primer reflejo con cada línea leída: linea.strip().
  • Leer con read()/readlines() archivos potencialmente grandes: cargan todo en RAM. El modo por defecto es iterar: for linea in f:.
  • Escribir fuera del with: tras el bloque, el archivo está cerrado; f.write(...) lanza ValueError: I/O operation on closed file. Todo el trabajo con f, dentro del bloque.
  • Olvidar convertir tipos al leer: del archivo todo vuelve como str. "12.50" + 1 es un TypeError; convierte con float()/int() nada más hacer split().
  • Consejo: los archivos de texto se inspeccionan gratis — ábrelos con cualquier editor para verificar qué escribió tu programa. Esa transparencia es una gran ventaja frente a formatos binarios, aprovéchala mientras depuras.

Ejercicios

Ejercicio 1: inventario nocturno

Escribe guardar_inventario(catalogo, ruta) que reciba el catálogo canónico dict[str, Libro] (05-06) y escriba en ruta una línea por libro con el formato titulo | precio | stock (precio con 2 decimales), en modo "w" — es el volcado completo de cierre de caja, se regenera entero cada noche. Pruébalo con los cuatro libros canónicos y abre el archivo con tu editor para comprobarlo.

Ejercicio 2: recargar el catálogo al abrir la tienda

Escribe cargar_inventario(ruta) que haga el viaje inverso: lea el archivo del ejercicio 1 línea a línea (iterando, no con read()), reconstruya cada Libro con los tipos correctos y devuelva el dict[str, Libro] con claves normalizadas (normalizar_titulo, 04-05). Si el archivo no existe, devuelve {} usando el paliativo Path.exists() — y deja un comentario recordando que el módulo 7 traerá la solución de verdad. Comprueba que cargar_inventario("inventario.txt")["hamlet"].precio_final(socio=True) devuelve 9.83.

Ejercicio 3: diario de apertura y cierre

Escribe anotar(evento) que añada a diario.txt (modo "a") una línea fecha | evento, con la fecha de date.today(). Llama a anotar("Apertura de Papyrus") y anotar("Cierre de caja"), ejecuta el script dos veces y comprueba que el diario conserva las cuatro líneas. Después escribe contar_eventos(ruta) que devuelva un Counter (04-06) de eventos leyendo el diario línea a línea.

Soluciones

Solución 1:

def guardar_inventario(catalogo, ruta):
    with open(ruta, "w", encoding="utf-8") as f:          # "w": volcado completo
        for libro in catalogo.values():
            f.write(f"{libro.titulo} | {libro.precio:.2f} | {libro.stock}\n")

guardar_inventario(catalogo, "inventario.txt")

Iteramos sobre values() (04-03) porque la clave normalizada es reconstruible; el título "bonito" vive en el objeto.

Solución 2:

from pathlib import Path

def cargar_inventario(ruta):
    if not Path(ruta).exists():        # paliativo hasta el try/except del módulo 7
        return {}
    catalogo = {}
    with open(ruta, "r", encoding="utf-8") as f:
        for linea in f:                               # línea a línea: memoria constante
            titulo, precio, stock = linea.strip().split(" | ")
            libro = Libro(titulo, float(precio), int(stock))   # str → tipos reales
            catalogo[normalizar_titulo(libro.titulo)] = libro
    return catalogo

catalogo = cargar_inventario("inventario.txt")
print(catalogo["hamlet"].precio_final(socio=True))    # 9.83 — la cifra canónica

El round-trip completo: los objetos que guardaste anoche vuelven a la vida esta mañana, validados por __post_init__ al reconstruirse.

Solución 3:

from datetime import date
from collections import Counter

def anotar(evento):
    with open("diario.txt", "a", encoding="utf-8") as f:
        f.write(f"{date.today()} | {evento}\n")

def contar_eventos(ruta):
    eventos = Counter()
    with open(ruta, "r", encoding="utf-8") as f:
        for linea in f:
            _, evento = linea.strip().split(" | ")
            eventos[evento] += 1
    return eventos

anotar("Apertura de Papyrus")
anotar("Cierre de caja")
print(contar_eventos("diario.txt"))
# tras dos ejecuciones: Counter({'Apertura de Papyrus': 2, 'Cierre de caja': 2})

El modo "a" acumula entre ejecuciones — eso es exactamente la persistencia que Papyrus necesitaba.

Conclusión

Papyrus ya tiene memoria a largo plazo: open() con el modo adecuado (r para leer, w para volcar, a para acumular, x para proteger), siempre dentro de un with que garantiza el cierre, siempre con encoding="utf-8" para que las tildes y las ñ sobrevivan al viaje, escribiendo \n a conciencia y limpiando con strip() al volver. La promesa del módulo 1 está saldada: ficha_libro.py deja huella en registro_altas.txt y el inventario resucita cada mañana. Pero nuestro formato titulo | precio | stock es un dialecto casero: lo inventamos nosotros, nadie más lo entiende, y se rompe el día que un título contenga una barra vertical. Existe un formato de texto tabular que Excel, Google Sheets y medio mundo hablan de nacimiento, con un módulo de la biblioteca estándar que resuelve por ti separadores, comillas y casos retorcidos: CSV. En la próxima lección, el catálogo y las ventas de Papyrus se mudan a él.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados