Bases de datos en Java con JDBC
En esta lección
- Qué es JDBC y qué es un driver
- Añadir el driver al proyecto
- Abrir una conexión
- No guardes la contraseña en el código
- Consultar datos: executeQuery y ResultSet
- Statement frente a PreparedStatement: la inyección SQL
- Modificar datos: executeUpdate
- Transacciones
- El patrón DAO: separar el acceso a datos
- Errores frecuentes
- Resumen
Casi todas las aplicaciones reales guardan sus datos en una base de datos: los clientes de una tienda, las reservas de un gimnasio o los préstamos de una biblioteca. En esta lección aprenderás a conectar un programa Java con MySQL usando JDBC: abrir la conexión, hacer consultas, insertar y modificar datos de forma segura, usar transacciones y organizar el código con el patrón DAO. Necesitas saber algo de SQL; si no, empieza por ¿Qué es SQL?.
Qué es JDBC y qué es un driver
JDBC (Java Database Connectivity) es la API estándar de Java para hablar con bases de datos relacionales. Está en el paquete java.sql, que viene con el JDK, y define interfaces como Connection, PreparedStatement o ResultSet.
Pero esas interfaces no saben hablar con MySQL, ni con PostgreSQL, ni con Oracle: cada base de datos tiene su propio protocolo. Quien lo implementa es el driver, una biblioteca que proporciona el fabricante de cada base de datos:
Tu programa ──usa──▶ java.sql (JDBC) ──▶ Driver de MySQL (Connector/J) ──red──▶ Servidor MySQL
La ventaja es que tu código solo usa las interfaces de java.sql. Si mañana cambias MySQL por PostgreSQL, cambias el driver y la URL de conexión, y el resto del código Java apenas cambia (el SQL, si es estándar, tampoco).
Seguiremos con la base de datos tienda del curso de SQL, con sus dos tablas:
clientes(id, nombre, ciudad, edad)pedidos(id, cliente_id, fecha, importe)
Si no las tienes, crea la base de datos (CREATE DATABASE tienda;) y ejecuta el script de la introducción a SQL.
Añadir el driver al proyecto
El driver de MySQL se llama MySQL Connector/J. Hay dos formas de añadirlo.
Con Maven
Si tu proyecto usa Maven (lo habitual en proyectos profesionales y en IntelliJ o NetBeans), añade esta dependencia al pom.xml dentro de <dependencies>:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.4.0</version>
</dependency>
Maven lo descarga solo. Consulta en Maven Central cuál es la última versión y usa esa.
Con el .jar a mano
También puedes descargar el .jar desde la web de MySQL (apartado Connector/J, opción Platform Independent) y añadirlo al classpath. En un IDE se hace desde las propiedades del proyecto (Libraries o Dependencies). Desde la terminal:
javac App.java
java -cp .:mysql-connector-j-9.4.0.jar App # Linux y macOS
java -cp ".;mysql-connector-j-9.4.0.jar" App # Windows (con punto y coma)
Si te olvidas del driver, el programa compila igualmente (porque java.sql está en el JDK), pero al conectar falla con este error:
No suitable driver found for jdbc:mysql://localhost:3306/tienda
Consejo: en apuntes antiguos verás
Class.forName("com.mysql.cj.jdbc.Driver");antes de conectar. Desde JDBC 4 (Java 6) ya no hace falta: el driver se registra solo al estar en el classpath.
Abrir una conexión
Para conectar necesitas tres datos: la URL, el usuario y la contraseña. La URL de JDBC tiene esta forma:
jdbc:mysql://localhost:3306/tienda
│ │ │ │ └─ base de datos
│ │ │ └─ puerto (3306 es el de MySQL)
│ │ └─ servidor (tu propio ordenador)
│ └─ tipo de base de datos (decide qué driver se usa)
└─ protocolo JDBC
La conexión se abre con DriverManager.getConnection, que devuelve un objeto Connection:
String url = "jdbc:mysql://localhost:3306/tienda";
String usuario = "tienda_app";
String contrasenia = "1234"; // solo para este primer ejemplo (lee más abajo)
try (Connection conexion = DriverManager.getConnection(url, usuario, contrasenia)) {
System.out.println("Conectado a " + conexion.getMetaData().getDatabaseProductName());
} catch (SQLException e) {
System.out.println("No se pudo conectar: " + e.getMessage());
}
// Conectado a MySQL
Fíjate en dos cosas:
- Una conexión es un recurso: ocupa memoria en tu programa y en el servidor, que admite un número limitado. Por eso se abre con try-with-resources, que la cierra siempre, haya error o no. Lo viste en excepciones.
- Casi todos los métodos de JDBC lanzan
SQLException, una excepción checked: tienes que capturarla o declararla conthrows.
No guardes la contraseña en el código
Escribir "1234" en el código es un error grave: el código acaba en GitHub o en un .jar que se puede descompilar, y con él la contraseña. Además, la de tu ordenador no es la del servidor de producción.
La solución más sencilla es leer los datos de conexión de variables de entorno con System.getenv:
static Connection abrirConexion() throws SQLException {
String url = System.getenv("DB_URL");
String usuario = System.getenv("DB_USUARIO");
String contrasenia = System.getenv("DB_CONTRASENIA");
if (url == null || usuario == null || contrasenia == null) {
throw new IllegalStateException("Faltan las variables de entorno de la base de datos");
}
return DriverManager.getConnection(url, usuario, contrasenia);
}
Las variables se definen fuera del programa: en la terminal, en la configuración de ejecución del IDE (Run Configuration → Environment variables) o en el servidor.
export DB_URL="jdbc:mysql://localhost:3306/tienda"
export DB_USUARIO="tienda_app"
export DB_CONTRASENIA="una-contrasenia-larga"
java -cp .:mysql-connector-j-9.4.0.jar App
Consejo: crea en MySQL un usuario solo para la aplicación, con permisos únicamente sobre la base de datos
tienda, en lugar de usarroot. Lo explica usuarios y permisos.
En el resto de la lección usaremos este método abrirConexion().
Consultar datos: executeQuery y ResultSet
Para lanzar un SELECT se crea una sentencia y se llama a executeQuery, que devuelve un ResultSet: un cursor que apunta a las filas del resultado de una en una.
String sql = "SELECT id, nombre, ciudad, edad FROM clientes ORDER BY id";
try (Connection conexion = abrirConexion();
Statement sentencia = conexion.createStatement();
ResultSet filas = sentencia.executeQuery(sql)) {
while (filas.next()) {
int id = filas.getInt("id");
String nombre = filas.getString("nombre");
String ciudad = filas.getString("ciudad");
int edad = filas.getInt("edad");
System.out.println(id + " - " + nombre + " (" + ciudad + ", " + edad + ")");
}
}
// 1 - Ana García (Santander, 34)
// 2 - Luis Pérez (Bilbao, 22)
// 3 - Marta Ruiz (Santander, 45)
// 4 - Pablo Díaz (Madrid, 17)
// 5 - Eva Martín (Madrid, 29)
- Al principio el cursor está antes de la primera fila. Cada
next()avanza una y devuelvefalsecuando no quedan más. Por eso elwhile (filas.next()). - Los
getXxx("columna")leen el valor de la fila actual. También admiten el número de columna empezando en 1 (getInt(1)), pero por nombre es más legible. Connection,StatementyResultSetse cierran los tres; el try-with-resources lo hace en orden inverso.
Cada tipo de SQL tiene su método:
| Tipo SQL | Método | Tipo Java |
|---|---|---|
INT | getInt | int |
VARCHAR | getString | String |
DECIMAL(10, 2) | getBigDecimal | BigDecimal |
DATE | getObject(col, LocalDate.class) | LocalDate |
BOOLEAN | getBoolean | boolean |
Para el dinero usa BigDecimal, no double, que no representa los decimales con exactitud. Y las fechas se leen directamente como LocalDate, que ya conoces de fechas con java.time:
// Dentro del while, con "SELECT id, fecha, importe FROM pedidos WHERE cliente_id = 1"
LocalDate fecha = filas.getObject("fecha", LocalDate.class);
BigDecimal importe = filas.getBigDecimal("importe");
System.out.println("Pedido " + filas.getInt("id") + ": " + fecha + " -> " + importe + " €");
// Pedido 1: 2026-09-01 -> 45.00 €
// Pedido 3: 2026-09-10 -> 12.99 €
Statement frente a PreparedStatement: la inyección SQL
Hasta ahora el SQL era fijo. Pero casi siempre depende de un dato que escribe el usuario, por ejemplo la ciudad por la que filtrar. La tentación es concatenar:
// MAL: nunca concatenes datos del usuario en el SQL
String sql = "SELECT nombre FROM clientes WHERE ciudad = '" + ciudad + "'";
Con ciudad = "Madrid" funciona. Pero imagina que alguien escribe esto en el formulario:
x' OR '1'='1
El SQL que llega a MySQL es:
SELECT nombre FROM clientes WHERE ciudad = 'x' OR '1'='1'
Como '1'='1' siempre es verdadero, devuelve todos los clientes. Esto es la inyección SQL: el usuario consigue que su texto se ejecute como código SQL. Con el mismo truco se puede saltar un login o, con otras variantes, leer o borrar tablas enteras. Es uno de los ataques más comunes en la web.
La solución es PreparedStatement. Escribes el SQL con ? en el lugar de cada dato y los rellenas después con setXxx(posición, valor), contando desde 1:
String sql = "SELECT nombre FROM clientes WHERE ciudad = ?";
try (PreparedStatement sentencia = conexion.prepareStatement(sql)) {
sentencia.setString(1, ciudad);
try (ResultSet filas = sentencia.executeQuery()) {
while (filas.next()) {
System.out.println(filas.getString("nombre"));
}
}
}
El SQL y los datos viajan por separado: MySQL sabe que lo que va en el ? es un valor, nunca código. Si alguien escribe x' OR '1'='1, se busca una ciudad que se llame literalmente así, y no sale ninguna fila.
Con varios parámetros, cada ? tiene su posición: en "... WHERE ciudad = ? AND edad >= ?" harías setString(1, "Madrid") y setInt(2, 18).
Cuidado: usa
PreparedStatementsiempre que el SQL lleve un dato variable. Además de ser seguro, se encarga de las comillas (O'Connorno rompe nada) y de convertir fechas y decimales.Statementsolo para SQL completamente fijo.
Modificar datos: executeUpdate
Para INSERT, UPDATE y DELETE se usa executeUpdate, que devuelve el número de filas afectadas:
String sql = "UPDATE clientes SET ciudad = ? WHERE id = ?";
try (PreparedStatement sentencia = conexion.prepareStatement(sql)) {
sentencia.setString(1, "Torrelavega");
sentencia.setInt(2, 2);
int filas = sentencia.executeUpdate();
System.out.println("Filas actualizadas: " + filas);
}
// Filas actualizadas: 1
Ese número es útil para comprobar que algo ha pasado de verdad: si devuelve 0, no existía ningún cliente con ese id. Con WHERE cliente_id = 1 en pedidos devolvería 2, porque Ana tiene dos pedidos.
Recuperar la clave generada
Normalmente el id lo genera la base de datos con AUTO_INCREMENT (lo viste en INSERT, UPDATE y DELETE). En las tablas del curso los id se escriben a mano; para este ejemplo, supón que has cambiado pedidos con ALTER TABLE pedidos MODIFY id INT AUTO_INCREMENT;.
Tras insertar, muchas veces necesitas saber qué id se ha asignado, por ejemplo para mostrar “Pedido número 6 registrado”. Se pide con Statement.RETURN_GENERATED_KEYS y se lee con getGeneratedKeys():
static int insertarPedido(Connection conexion, int clienteId, LocalDate fecha, BigDecimal importe)
throws SQLException {
String sql = "INSERT INTO pedidos (cliente_id, fecha, importe) VALUES (?, ?, ?)";
try (PreparedStatement sentencia = conexion.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
sentencia.setInt(1, clienteId);
sentencia.setObject(2, fecha);
sentencia.setBigDecimal(3, importe);
sentencia.executeUpdate();
try (ResultSet claves = sentencia.getGeneratedKeys()) {
if (claves.next()) {
return claves.getInt(1);
}
throw new SQLException("No se generó ningún id");
}
}
}
int id = insertarPedido(conexion, 4, LocalDate.of(2026, 9, 20), new BigDecimal("18.75"));
System.out.println("Pedido número " + id + " registrado"); // Pedido número 6 registrado
Transacciones
Por defecto, JDBC trabaja en modo autocommit: cada executeUpdate se confirma en cuanto se ejecuta. Es cómodo, pero peligroso cuando varias instrucciones deben ir juntas.
Ejemplo: borrar un cliente exige borrar antes sus pedidos (la clave foránea no deja borrar un cliente que tiene pedidos). Si se borran los pedidos y luego falla el borrado del cliente, te quedas con un cliente sin su historial. O todo o nada: eso es una transacción.
En JDBC se controla con tres métodos de Connection:
setAutoCommit(false): empieza a agrupar instrucciones.commit(): confirma todo lo hecho.rollback(): lo deshace todo.
static void borrarClienteConPedidos(Connection conexion, int clienteId) throws SQLException {
String borrarPedidos = "DELETE FROM pedidos WHERE cliente_id = ?";
String borrarCliente = "DELETE FROM clientes WHERE id = ?";
conexion.setAutoCommit(false);
try (PreparedStatement ps1 = conexion.prepareStatement(borrarPedidos);
PreparedStatement ps2 = conexion.prepareStatement(borrarCliente)) {
ps1.setInt(1, clienteId);
int pedidos = ps1.executeUpdate();
ps2.setInt(1, clienteId);
int clientes = ps2.executeUpdate();
if (clientes == 0) {
throw new SQLException("No existe el cliente " + clienteId);
}
conexion.commit(); // todo bien: se guardan los dos DELETE
System.out.println("Borrado el cliente " + clienteId + " y sus " + pedidos + " pedidos");
} catch (SQLException e) {
conexion.rollback(); // algo falló: se deshacen los dos
throw e;
} finally {
conexion.setAutoCommit(true);
}
}
borrarClienteConPedidos(conexion, 1); // Borrado el cliente 1 y sus 2 pedidos
Nota: cada ejemplo de esta lección parte de los datos originales del curso. Si ejecutas este borrado, Ana desaparecerá de los resultados de los ejemplos siguientes.
El catch hace rollback y vuelve a lanzar la excepción para que quien llamó se entere del fallo. El finally devuelve la conexión a su modo normal.
El patrón DAO: separar el acceso a datos
Si repartes SQL por todo el programa (en el menú, en los formularios, en los cálculos), pronto es imposible de mantener. El patrón DAO (Data Access Object) reúne en una sola clase todo el acceso a una tabla. El resto del programa trabaja con objetos Java y no ve ni una línea de SQL.
Primero, la clase que representa una fila. Un record es perfecto:
public record Cliente(int id, String nombre, String ciudad, int edad) {}
Después, el DAO, con un método por operación:
public class ClienteDAO {
private final Connection conexion;
public ClienteDAO(Connection conexion) {
this.conexion = conexion;
}
public Optional<Cliente> buscarPorId(int id) throws SQLException {
String sql = "SELECT id, nombre, ciudad, edad FROM clientes WHERE id = ?";
try (PreparedStatement sentencia = conexion.prepareStatement(sql)) {
sentencia.setInt(1, id);
try (ResultSet filas = sentencia.executeQuery()) {
if (filas.next()) {
return Optional.of(crearCliente(filas));
}
return Optional.empty();
}
}
}
public List<Cliente> buscarPorCiudad(String ciudad) throws SQLException {
String sql = "SELECT id, nombre, ciudad, edad FROM clientes WHERE ciudad = ? ORDER BY nombre";
List<Cliente> clientes = new ArrayList<>();
try (PreparedStatement sentencia = conexion.prepareStatement(sql)) {
sentencia.setString(1, ciudad);
try (ResultSet filas = sentencia.executeQuery()) {
while (filas.next()) {
clientes.add(crearCliente(filas));
}
}
}
return clientes;
}
public void insertar(Cliente cliente) throws SQLException {
String sql = "INSERT INTO clientes (id, nombre, ciudad, edad) VALUES (?, ?, ?, ?)";
try (PreparedStatement sentencia = conexion.prepareStatement(sql)) {
sentencia.setInt(1, cliente.id());
sentencia.setString(2, cliente.nombre());
sentencia.setString(3, cliente.ciudad());
sentencia.setInt(4, cliente.edad());
sentencia.executeUpdate();
}
}
// cambiarCiudad, borrar... siguen el mismo esquema con executeUpdate
// Convierte la fila actual del ResultSet en un Cliente
private Cliente crearCliente(ResultSet filas) throws SQLException {
return new Cliente(filas.getInt("id"), filas.getString("nombre"),
filas.getString("ciudad"), filas.getInt("edad"));
}
}
buscarPorId devuelve un Optional porque puede que el cliente no exista: así quien lo llama está obligado a pensar en ese caso. Y el método privado crearCliente evita repetir la conversión de fila a objeto.
Ahora el programa principal queda limpio:
try (Connection conexion = abrirConexion()) {
ClienteDAO dao = new ClienteDAO(conexion);
dao.buscarPorCiudad("Santander").forEach(System.out::println);
// Cliente[id=1, nombre=Ana García, ciudad=Santander, edad=34]
// Cliente[id=3, nombre=Marta Ruiz, ciudad=Santander, edad=45]
dao.insertar(new Cliente(6, "Iker Sainz", "Santander", 31));
System.out.println(dao.buscarPorId(6).orElseThrow());
// Cliente[id=6, nombre=Iker Sainz, ciudad=Santander, edad=31]
System.out.println(dao.buscarPorId(99).isPresent()); // false
} catch (SQLException e) {
System.out.println("Error de base de datos: " + e.getMessage());
}
Si mañana cambias de base de datos o de tecnología (por ejemplo, a JPA o Spring Data), solo reescribes el DAO; el resto del programa no se toca.
Errores frecuentes
- Olvidar el driver en el classpath:
No suitable driver found. - Concatenar datos en el SQL. Abre la puerta a la inyección SQL. Usa
PreparedStatementcon?. - Contar los parámetros desde 0. En JDBC, tanto
setXxxcomogetXxx(numero)empiezan en 1. - Leer el
ResultSetsin llamar anext(). El cursor empieza antes de la primera fila y daSQLException. - No cerrar conexiones. Las olvidadas ocupan huecos en el servidor hasta agotarlos. Usa try-with-resources.
- Usar
executeQuerycon unUPDATEoexecuteUpdatecon unSELECT. LanzaSQLException. - Olvidar
commit()trassetAutoCommit(false): al cerrar la conexión los cambios se pierden. getIntcon valoresNULL. Devuelve0, nonull. Si la columna admite nulos, comprueba despuésfilas.wasNull()o usagetObject("edad", Integer.class).- Contraseñas en el código. Usa variables de entorno.
Resumen
| Tarea | Código |
|---|---|
| Driver de MySQL | com.mysql:mysql-connector-j (Maven) o el .jar en el classpath |
| URL | jdbc:mysql://localhost:3306/tienda |
| Conectar | try (Connection c = DriverManager.getConnection(url, usuario, clave)) { } |
| Datos de conexión | System.getenv("DB_CONTRASENIA") |
| Sentencia con datos | c.prepareStatement("... WHERE id = ?") + setInt(1, id) |
| Consultar | executeQuery() → ResultSet + while (filas.next()) |
| Leer columnas | getInt, getString, getBigDecimal, getObject(col, LocalDate.class) |
| Modificar | executeUpdate() → filas afectadas |
| Id generado | RETURN_GENERATED_KEYS + getGeneratedKeys() |
| Transacción | setAutoCommit(false), commit(), rollback() |
| Organizar el código | Una clase DAO por tabla |
| Errores | SQLException (checked) |
Con esto ya tienes todo lo necesario para que tus programas Java guarden datos de forma permanente y segura, igual que las aplicaciones profesionales.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.