Pandas básico: DataFrames, Series y análisis de datos en Python

📅 Actualizado en marzo 2026 📊 Nivel: Principiante ⏱️ 16 min de lectura

Si NumPy es el motor matemático de Python, Pandas es el volante con el que cualquiera puede conducirlo. No necesitas saber de álgebra lineal ni de memoria contigua: le das un CSV, un Excel o una base de datos SQL, y Pandas te devuelve un DataFrame con el que puedes filtrar, agrupar, combinar y analizar igual que en SQL o Excel, pero con todo el poder de Python. Es la herramienta más usada en Data Science por una razón sencilla: hace manejable lo que sin ella sería caótico.

Medallón de Heráclito de Éfeso, filósofo griego presocrático conocido por la doctrina del flujo perpetuo
Panta rhei
«Todo fluye»
Heráclito de Éfeso · Filósofo griego · 535 a.C. – 475 a.C.
Heráclito enseñó que la realidad no es un estado fijo sino un flujo continuo de cambio: el río nunca es el mismo porque el agua que lo constituye está en permanente movimiento. Los datos también fluyen. Un dataset recibido hoy tendrá nulos, columnas mal nombradas, tipos incorrectos y filas duplicadas. El análisis de datos es precisamente ese proceso de transformación constante: limpiar, filtrar, agrupar, combinar. Pandas es la herramienta que permite navegar ese flujo sin perder el control del dato en ningún momento del trayecto.

¿Qué es Pandas y por qué domina el análisis de datos?

Pandas nació en 2008 en el contexto de la gestión de activos financieros: Wes McKinney necesitaba una herramienta que permitiera manipular series temporales y tablas de datos con la flexibilidad de Python y la velocidad de NumPy. El resultado fue una librería que en quince años se ha convertido en el estándar de facto del análisis de datos en Python, con más de 100 millones de descargas mensuales.

Lo que hace especial a Pandas es la combinación de dos ideas: el DataFrame (una tabla con filas y columnas etiquetadas, como en SQL o en R) y la integración transparente con todo el ecosistema Python (NumPy, Matplotlib, scikit-learn, SQLAlchemy). No reemplaza a Excel; hace lo que Excel no puede: manejar millones de filas, automatizar transformaciones complejas y reproducir cada paso del análisis con código.

pip install pandas
import pandas as pd
import numpy as np  # Pandas usa NumPy internamente

print(pd.__version__)  # 2.x es la versión actual
Discos de vinilo organizados con fichas de cartón etiquetadas por inicial en una tienda de música
Una colección de discos de vinilo ordenada con fichas de cartón es un DataFrame analógico: cada disco es una fila, cada ficha es un índice, las columnas serían artista, año, género, estado. Buscar todos los discos de jazz anteriores a 1970 en esa colección a mano es tedioso; en Pandas, es una línea: df[(df['genero']=='jazz') & (df['año'] < 1970)]. Fuente: Pexels (licencia libre).

DataFrame y Series: las dos estructuras fundamentales

Pandas tiene dos estructuras de datos principales. La Serie es un array unidimensional con etiquetas (un índice). El DataFrame es una colección de Series que comparten el mismo índice: una tabla con filas y columnas etiquetadas.

import pandas as pd

# ── Serie ──────────────────────────────────────────────────────
s = pd.Series([10, 20, 30, 40], index=['a', 'b', 'c', 'd'])
print(s['b'])      # 20
print(s.mean())    # 25.0
print(s.dtype)     # int64

# Serie desde dict
s2 = pd.Series({'Madrid': 3.3, 'Barcelona': 1.6, 'Valencia': 0.8})
s2['Madrid']  # 3.3

# ── DataFrame ──────────────────────────────────────────────────
df = pd.DataFrame({
    'nombre': ['Ana', 'Luis', 'María', 'Pedro'],
    'edad':   [28, 34, 22, 41],
    'ciudad': ['Madrid', 'Barcelona', 'Sevilla', 'Madrid'],
    'ventas': [1200.0, 850.0, 2100.0, 980.0],
})

print(df)
#    nombre  edad     ciudad  ventas
# 0     Ana    28     Madrid  1200.0
# 1    Luis    34  Barcelona   850.0
# 2   María    22    Sevilla  2100.0
# 3   Pedro    41     Madrid   980.0

print(df.shape)    # (4, 4) → 4 filas, 4 columnas
print(df.dtypes)   # object, int64, object, float64
print(df.index)    # RangeIndex(start=0, stop=4, step=1)
print(df.columns)  # Index(['nombre', 'edad', 'ciudad', 'ventas'])

# Acceder a columna → Serie
df['edad']              # Serie con los valores de edad
type(df['edad'])        # 

# Acceder a múltiples columnas → DataFrame
df[['nombre', 'ventas']]
💡 DataFrame desde NumPy: También puedes crear un DataFrame a partir de un array NumPy: pd.DataFrame(np.random.randn(4, 3), columns=['a', 'b', 'c']). Esto es habitual en Machine Learning, donde los datos ya están en formato matricial.

Leer y explorar datos desde archivos

La primera acción en cualquier proyecto de análisis de datos es cargar el dataset. Pandas tiene funciones de lectura para casi cualquier formato.

import pandas as pd

# ── Leer CSV ───────────────────────────────────────────────────
df = pd.read_csv('ventas.csv')
df = pd.read_csv('ventas.csv', sep=';')              # separador punto y coma
df = pd.read_csv('ventas.csv', encoding='utf-8')     # codificación explícita
df = pd.read_csv('ventas.csv', index_col='id')       # columna como índice
df = pd.read_csv('ventas.csv', parse_dates=['fecha'])# parsear fechas
df = pd.read_csv('ventas.csv', usecols=['a','b','c'])# solo ciertas columnas
df = pd.read_csv('ventas.csv', nrows=1000)           # primeras N filas

# ── Otros formatos ─────────────────────────────────────────────
df = pd.read_excel('libro.xlsx', sheet_name='Hoja1')
df = pd.read_json('datos.json')
df = pd.read_sql('SELECT * FROM ventas', connection)  # SQLAlchemy o sqlite3

# ── Exploración inicial (siempre hacerlo primero) ──────────────
df.head(5)         # primeras 5 filas
df.tail(3)         # últimas 3 filas
df.sample(5)       # 5 filas aleatorias
df.info()          # tipos de dato, nulos, memoria
df.describe()      # estadísticas básicas de columnas numéricas
df.shape           # (filas, columnas)
df.dtypes          # tipo de cada columna
df.isnull().sum()  # ← SIEMPRE ejecutar: cuenta nulos por columna

# ── Exportar ───────────────────────────────────────────────────
df.to_csv('resultado.csv', index=False)
df.to_excel('resultado.xlsx', index=False)
df.to_json('resultado.json', orient='records')
📋 La secuencia de exploración: Al recibir un dataset desconocido, la secuencia habitual es: df.shapedf.info()df.describe()df.isnull().sum()df.head(). En cinco pasos entiendes la estructura, los tipos, las estadísticas, los valores ausentes y el aspecto real de los datos.

Selección con loc e iloc

Aquí es donde muchos principiantes se pierden: Pandas tiene dos sistemas de indexación. loc trabaja con etiquetas (nombres del índice y las columnas). iloc trabaja con posiciones enteras (0, 1, 2...). La confusión viene de que cuando el índice es el rango por defecto (0, 1, 2...), ambos dan el mismo resultado para filas, pero siguen siendo semánticamente distintos.

df = pd.DataFrame({
    'nombre': ['Ana', 'Luis', 'María', 'Pedro'],
    'edad':   [28, 34, 22, 41],
    'ciudad': ['Madrid', 'Barcelona', 'Sevilla', 'Madrid'],
}, index=[10, 20, 30, 40])  # índice no estándar para ilustrar la diferencia

# ── loc: por ETIQUETA ──────────────────────────────────────────
df.loc[20]                   # fila con índice 20 (Luis)
df.loc[10:30]                # filas de índice 10 a 30 (¡inclusive!)
df.loc[10, 'edad']           # fila 10, columna 'edad'
df.loc[10:30, 'nombre':'ciudad']  # rango de filas y columnas
df.loc[df['edad'] > 25]      # filtrado booleano

# ── iloc: por POSICION ─────────────────────────────────────────
df.iloc[0]                   # primera fila (sea cual sea su índice)
df.iloc[1:3]                 # filas en posición 1 y 2 (no incluye 3)
df.iloc[0, 1]                # primera fila, segunda columna
df.iloc[:, 0:2]              # todas las filas, primeras 2 columnas
df.iloc[-1]                  # última fila

# ── Acceso rápido a columna ────────────────────────────────────
df['nombre']          # Serie (corchetes siempre para columnas)
df[['nombre','edad']] # DataFrame (doble corchete = lista de columnas)
⚠️ Trampa clásica de loc: df.loc[10:30] incluye ambos extremos (10 y 30). df.iloc[1:3] no incluye el extremo derecho (solo posiciones 1 y 2). Este comportamiento inconsistente entre loc e iloc sorprende siempre la primera vez.

Filtrado booleano y limpieza de nulos

El filtrado booleano es el mecanismo principal para seleccionar filas según condiciones. Pandas evalúa la condición sobre cada fila y devuelve un array de True/False que se usa como máscara.

df = pd.DataFrame({
    'nombre': ['Ana', 'Luis', 'María', 'Pedro', 'Sara'],
    'edad':   [28, 34, 22, 41, 29],
    'ciudad': ['Madrid', 'Barcelona', 'Madrid', 'Madrid', 'Sevilla'],
    'ventas': [1200.0, 850.0, None, 980.0, 2100.0],
})

# ── Filtrado simple ────────────────────────────────────────────
df[df['edad'] > 28]                          # mayores de 28
df[df['ciudad'] == 'Madrid']                 # de Madrid

# ── Condiciones combinadas (& para AND, | para OR) ─────────────
df[(df['edad'] > 25) & (df['ciudad'] == 'Madrid')]   # AND
df[(df['ciudad'] == 'Madrid') | (df['ciudad'] == 'Sevilla')]  # OR
df[~(df['ciudad'] == 'Madrid')]              # negación (NOT)

# ── Métodos útiles ─────────────────────────────────────────────
df[df['ciudad'].isin(['Madrid', 'Sevilla'])]    # en lista de valores
df[df['edad'].between(25, 35)]                  # rango inclusivo
df[df['nombre'].str.contains('a', case=False)]  # contiene texto
df.query('edad > 25 and ciudad == "Madrid"')    # sintaxis SQL-like

# ── Nulos: detectar y tratar ───────────────────────────────────
df.isnull()                # DataFrame booleano
df.isnull().sum()          # cuenta de nulos por columna
df.isnull().sum().sum()    # total de nulos en el DataFrame

df.dropna()                # eliminar filas con algún NaN
df.dropna(subset=['ventas'])  # eliminar solo si NaN en 'ventas'
df.dropna(axis=1)          # eliminar columnas con algún NaN

df.fillna(0)               # reemplazar NaN por 0
df.fillna({'ventas': df['ventas'].mean()})  # rellenar con media
df['ventas'].fillna(method='ffill')         # forward fill (valor anterior)

# ── Duplicados ─────────────────────────────────────────────────
df.duplicated()            # máscara de filas duplicadas
df.drop_duplicates()       # eliminar filas duplicadas
df.drop_duplicates(subset=['ciudad'])  # duplicados en columna específica
Mujer con mascarilla seleccionando productos de una estantería de supermercado con frutas y hortalizas
El filtrado en un supermercado es exactamente lo que hace Pandas: de toda la estantería (el DataFrame completo), la persona selecciona solo los productos que cumplen sus condiciones (tipo de fruta, precio, fecha de caducidad). La diferencia es que en Pandas puedes encadenar decenas de condiciones simultáneas sobre millones de registros en milisegundos. Fuente: Pexels (licencia libre).
Diagrama de Pandas: anatomía del DataFrame con índice y columnas, selección loc e iloc, lectura y exploración de datos, filtrado booleano, groupby con funciones de agregación, merge y concat, y transformación con apply
El mapa completo de Pandas: cómo está estructurado el DataFrame, la diferencia entre loc y iloc con ejemplos visuales, cómo filtrar y limpiar datos, y el ciclo groupby-aggregate-sort que aparece en prácticamente cualquier análisis real. Infografía: Ciberaula.

Manipulación: ordenar, renombrar y transformar

Una vez que tienes los datos cargados y filtrados, el siguiente paso habitual es transformarlos: cambiar nombres de columnas, crear columnas derivadas, convertir tipos, ordenar. Pandas tiene un método para cada uno de estos casos.

import pandas as pd

df = pd.DataFrame({
    'nombre': ['Ana', 'Luis', 'María', 'Pedro'],
    'edad':   [28, 34, 22, 41],
    'ventas': [1200.0, 850.0, 2100.0, 980.0],
    'fecha':  ['2025-01', '2025-02', '2025-01', '2025-03'],
})

# ── Renombrar columnas ─────────────────────────────────────────
df.rename(columns={'nombre': 'Nombre', 'edad': 'Edad'})
df.columns = ['nombre', 'edad', 'ventas', 'fecha']  # asignar todos

# ── Añadir columnas ────────────────────────────────────────────
df['comision'] = df['ventas'] * 0.05            # operación vectorizada
df['senior']   = df['edad'] >= 35               # columna booleana
df['categoria'] = pd.cut(df['edad'], bins=[0, 30, 40, 100],
                          labels=['joven', 'adulto', 'senior'])

# ── apply: función personalizada ───────────────────────────────
df['nombre_upper'] = df['nombre'].apply(lambda x: x.upper())
df['rango_ventas'] = df['ventas'].apply(
    lambda v: 'alto' if v >= 1000 else 'bajo'
)
# apply por fila (axis=1)
df['resumen'] = df.apply(
    lambda row: f"{row['nombre']} ({row['edad']} años)", axis=1
)

# ── Eliminar columnas o filas ──────────────────────────────────
df.drop(columns=['comision'])          # eliminar columna
df.drop(index=[0, 2])                  # eliminar filas por índice

# ── Ordenar ────────────────────────────────────────────────────
df.sort_values('ventas', ascending=False)      # descendente
df.sort_values(['fecha', 'ventas'])            # por múltiples columnas
df.nlargest(3, 'ventas')                       # top 3 por ventas
df.nsmallest(2, 'edad')                        # 2 más jóvenes

# ── Tipos de dato ──────────────────────────────────────────────
df['edad'].astype(float)                  # convertir a float
pd.to_datetime(df['fecha'])               # convertir a datetime
df['fecha'] = pd.to_datetime(df['fecha'])
df['fecha'].dt.year                       # extraer año
df['fecha'].dt.month                      # extraer mes

# ── Strings ────────────────────────────────────────────────────
df['nombre'].str.lower()
df['nombre'].str.strip()
df['nombre'].str.replace(' ', '_')
df['nombre'].str.startswith('A')

Agrupación con groupby

El patrón split-apply-combine de groupby es uno de los más potentes y frecuentes en análisis de datos: divide el DataFrame en grupos según los valores de una columna, aplica una función a cada grupo, y combina los resultados. Es el equivalente Python del GROUP BY de SQL.

df = pd.DataFrame({
    'depto':   ['Ventas','Ventas','IT','IT','Marketing','Marketing'],
    'nombre':  ['Ana','Luis','María','Pedro','Sara','Tom'],
    'ventas':  [1200, 850, 2100, 980, 1500, 760],
    'edad':    [28, 34, 22, 41, 29, 35],
})

# ── Agrupación simple ──────────────────────────────────────────
df.groupby('depto')['ventas'].sum()
# depto
# IT           3080
# Marketing    2260
# Ventas       2050

df.groupby('depto')['ventas'].mean()
df.groupby('depto')['ventas'].count()
df.groupby('depto')['ventas'].max()

# ── Múltiples funciones de agregación ─────────────────────────
df.groupby('depto').agg({
    'ventas': ['sum', 'mean', 'max'],
    'edad':   'mean',
})

# ── Agrupar por múltiples columnas ─────────────────────────────
df.groupby(['depto', 'nombre'])['ventas'].sum()

# ── Obtener DataFrame limpio (reset_index) ─────────────────────
resumen = (
    df.groupby('depto')['ventas']
    .sum()
    .reset_index()                      # convierte el índice en columna
    .sort_values('ventas', ascending=False)
    .rename(columns={'ventas': 'total_ventas'})
)
print(resumen)
#        depto  total_ventas
# 1         IT          3080
# 2  Marketing          2260
# 0     Ventas          2050

# ── apply con función personalizada ───────────────────────────
def rango(serie):
    return serie.max() - serie.min()

df.groupby('depto')['ventas'].apply(rango)
# depto
# IT           1120
# Marketing     740
# Ventas        350

# ── size y nunique ─────────────────────────────────────────────
df.groupby('depto').size()             # número de filas por grupo
df.groupby('depto')['nombre'].nunique()  # valores únicos por grupo
💡 Encadenamiento de métodos: En Pandas es idiomático encadenar métodos en lugar de crear variables intermedias: df.groupby('depto')['ventas'].sum().reset_index().sort_values('ventas', ascending=False). Usar paréntesis al inicio y al final permite romper la cadena en varias líneas sin perder legibilidad.

Combinar DataFrames: merge y concat

En el trabajo real raramente los datos viven en una sola tabla. merge implementa los JOINs de SQL (inner, left, right, outer). concat apila DataFrames verticalmente (más filas) u horizontalmente (más columnas) sin necesidad de una columna común.

import pandas as pd

clientes = pd.DataFrame({
    'id':      [1, 2, 3, 4],
    'nombre':  ['Ana', 'Luis', 'María', 'Pedro'],
    'ciudad':  ['Madrid', 'Barcelona', 'Sevilla', 'Madrid'],
})

pedidos = pd.DataFrame({
    'pedido_id': [101, 102, 103, 104, 105],
    'cliente_id':[1, 2, 1, 3, 5],       # cliente_id=5 no existe en clientes
    'importe':   [120.0, 85.0, 210.0, 60.0, 95.0],
})

# ── merge (JOIN) ───────────────────────────────────────────────
# INNER: solo filas con clave en AMBAS tablas
pd.merge(clientes, pedidos, left_on='id', right_on='cliente_id')
# Ana (2 pedidos), Luis (1), María (1) → cliente_id=5 excluido

# LEFT: todas las filas del izquierdo, NaN si no hay coincidencia
pd.merge(clientes, pedidos, left_on='id', right_on='cliente_id',
         how='left')  # Pedro aparece con NaN en pedido_id e importe

# RIGHT: todas del derecho
pd.merge(clientes, pedidos, left_on='id', right_on='cliente_id',
         how='right')  # pedido 105 aparece, cliente_id=5 sin datos

# OUTER: todos de ambas tablas, NaN donde no haya coincidencia
pd.merge(clientes, pedidos, left_on='id', right_on='cliente_id',
         how='outer')

# Merge cuando la columna tiene el mismo nombre en ambas tablas
df_a = pd.DataFrame({'id':[1,2], 'valor_a':[10,20]})
df_b = pd.DataFrame({'id':[1,3], 'valor_b':[100,300]})
pd.merge(df_a, df_b, on='id')  # solo fila con id=1

# ── concat ─────────────────────────────────────────────────────
enero = pd.DataFrame({'fecha':['2025-01-01','2025-01-15'],
                      'ventas':[1200, 850]})
febrero = pd.DataFrame({'fecha':['2025-02-01','2025-02-20'],
                        'ventas':[980, 2100]})

# Apilar filas (por defecto)
todo = pd.concat([enero, febrero], ignore_index=True)
# ignore_index=True reindexar desde 0 en lugar de mantener índices originales

# Apilar columnas
pd.concat([df_a, df_b], axis=1)  # pega columnas lado a lado
Ficha de referencia rápida de Pandas en cuatro columnas: crear y leer DataFrames, selección con loc e iloc y filtrado booleano, manipulación y transformación de columnas, y groupby con estadística y exportar
La referencia completa de Pandas en una página: desde la creación y lectura de datos hasta la exportación, pasando por la selección con loc e iloc, el filtrado con condiciones booleanas, la transformación de columnas con apply, y las operaciones de agrupación y estadística. Ficha de referencia: Ciberaula.

❓ Preguntas frecuentes

❓ Preguntas frecuentes sobre Pandas básico: DataFrames, Series y análisis de datos en Python

Las dudas más comunes respondidas de forma clara y directa.

Una Serie es una columna con etiquetas (como un array NumPy indexado). Un DataFrame es una colección de Series que comparten el mismo índice, es decir, una tabla. En la práctica, casi siempre trabajas con DataFrames porque los datos reales tienen múltiples variables. Las Series aparecen al seleccionar una columna (df["col"]), al agrupar y agregar, o al hacer operaciones estadísticas sobre una sola variable. Pensar en el DataFrame como una hoja de cálculo y en la Serie como una columna de esa hoja es una analogía válida, aunque imprecisa: Pandas es mucho más potente porque puede filtrar, transformar, combinar y agregar de formas que ningún Excel permite de forma eficiente.
La diferencia es el sistema de referencia: loc trabaja con etiquetas (el nombre del índice o la columna), iloc trabaja con posiciones enteras (0, 1, 2...). Si tu DataFrame tiene un índice de strings como "Madrid", "Barcelona", "Sevilla", entonces df.loc["Barcelona"] busca por etiqueta y df.iloc[1] busca por posición (el segundo elemento). Cuando el índice es el rango por defecto (0, 1, 2...), loc e iloc dan el mismo resultado para filas, pero siguen siendo distintos para columnas: df.iloc[:, 0] selecciona la primera columna por posición, mientras que df.loc[:, "nombre"] la selecciona por nombre. La regla práctica: usa loc cuando sabes el nombre, usa iloc cuando sabes la posición.
Pandas representa los valores ausentes como NaN (Not a Number), que es un valor de punto flotante de Python. Para detectarlos: df.isnull() devuelve una máscara booleana, df.isnull().sum() cuenta los nulos por columna y es lo primero que deberías ejecutar al recibir un dataset nuevo. Para tratarlos tienes dos opciones principales: dropna() elimina filas (o columnas) que contengan algún NaN, y fillna() los rellena con un valor que tú elijas (cero, la media de la columna, el valor anterior con method="ffill"). La estrategia depende del dominio: en datos médicos nunca elimines sin entender por qué falta; en logs de sensores, el relleno con el valor anterior suele tener sentido físico.
groupby implementa el patrón split-apply-combine: divide el DataFrame en grupos según los valores de una o más columnas, aplica una función a cada grupo por separado, y combina los resultados en un nuevo DataFrame. El objeto que devuelve df.groupby("col") no ejecuta nada todavía: es una promesa de agrupación. La ejecución ocurre cuando encadenas una función de agregación: .sum(), .mean(), .count(), .agg(). Una confusión frecuente: groupby devuelve un objeto GroupBy, no un DataFrame. Para obtener un DataFrame limpio, encadena .reset_index() después de la agregación. Para aplicar funciones distintas a distintas columnas, usa .agg({"ventas": "sum", "edad": "mean"}).
concat apila DataFrames: o añade filas (axis=0, por defecto) o añade columnas (axis=1). No necesita una columna de unión; simplemente pega las estructuras. merge hace una operación similar a un JOIN de SQL: combina dos DataFrames basándose en una o más columnas que actúan como clave. Los tipos de merge son los mismos que los JOINs de SQL: inner (solo filas con clave en ambos), left (todas las filas del izquierdo, NaN donde no haya coincidencia en el derecho), right, y outer (unión completa). La confusión más común: merge con how="left" no añade filas del DataFrame derecho que no tengan correspondencia en el izquierdo; en cambio outer sí las incluye todas, rellenando con NaN donde falten datos.
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Pandas básico: DataFrames, Series y análisis de datos en Python? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!