Saltar al contenido
jdbc.java · devschool

Bases de datos en Java con JDBC

Lección 26 de 26 · 15 min de lectura · Actualizado el

En esta lección
  1. Qué es JDBC y qué es un driver
  2. Añadir el driver al proyecto
  3. Abrir una conexión
  4. No guardes la contraseña en el código
  5. Consultar datos: executeQuery y ResultSet
  6. Statement frente a PreparedStatement: la inyección SQL
  7. Modificar datos: executeUpdate
  8. Transacciones
  9. El patrón DAO: separar el acceso a datos
  10. Errores frecuentes
  11. 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 con throws.

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 usar root. 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 devuelve false cuando no quedan más. Por eso el while (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, Statement y ResultSet se cierran los tres; el try-with-resources lo hace en orden inverso.

Cada tipo de SQL tiene su método:

Tipo SQLMétodoTipo Java
INTgetIntint
VARCHARgetStringString
DECIMAL(10, 2)getBigDecimalBigDecimal
DATEgetObject(col, LocalDate.class)LocalDate
BOOLEANgetBooleanboolean

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 PreparedStatement siempre que el SQL lleve un dato variable. Además de ser seguro, se encarga de las comillas (O'Connor no rompe nada) y de convertir fechas y decimales. Statement solo 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 PreparedStatement con ?.
  • Contar los parámetros desde 0. En JDBC, tanto setXxx como getXxx(numero) empiezan en 1.
  • Leer el ResultSet sin llamar a next(). El cursor empieza antes de la primera fila y da SQLException.
  • No cerrar conexiones. Las olvidadas ocupan huecos en el servidor hasta agotarlos. Usa try-with-resources.
  • Usar executeQuery con un UPDATE o executeUpdate con un SELECT. Lanza SQLException.
  • Olvidar commit() tras setAutoCommit(false): al cerrar la conexión los cambios se pierden.
  • getInt con valores NULL. Devuelve 0, no null. Si la columna admite nulos, comprueba después filas.wasNull() o usa getObject("edad", Integer.class).
  • Contraseñas en el código. Usa variables de entorno.

Resumen

TareaCódigo
Driver de MySQLcom.mysql:mysql-connector-j (Maven) o el .jar en el classpath
URLjdbc:mysql://localhost:3306/tienda
Conectartry (Connection c = DriverManager.getConnection(url, usuario, clave)) { }
Datos de conexiónSystem.getenv("DB_CONTRASENIA")
Sentencia con datosc.prepareStatement("... WHERE id = ?") + setInt(1, id)
ConsultarexecuteQuery() → ResultSet + while (filas.next())
Leer columnasgetInt, getString, getBigDecimal, getObject(col, LocalDate.class)
ModificarexecuteUpdate() → filas afectadas
Id generadoRETURN_GENERATED_KEYS + getGeneratedKeys()
TransacciónsetAutoCommit(false), commit(), rollback()
Organizar el códigoUna clase DAO por tabla
ErroresSQLException (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

[Java] Con este código, ¿qué texto escrito por el usuario haría que se mostraran TODOS los clientes?
String sql = "SELECT nombre FROM clientes WHERE ciudad = '" + ciudad + "'";
ResultSet filas = sentencia.executeQuery(sql);

[Java] ¿Qué error tiene este código?
String sql = "SELECT nombre FROM clientes WHERE ciudad = ?";
try (PreparedStatement sentencia = conexion.prepareStatement(sql)) {
    sentencia.setString(0, "Santander");
    ResultSet filas = sentencia.executeQuery();
    // ...
}

[Java] Con las tablas del curso (Pablo Díaz y Eva Martín viven en Madrid), ¿qué imprime este código?
String sql = "UPDATE clientes SET ciudad = ? WHERE ciudad = ?";
try (PreparedStatement sentencia = conexion.prepareStatement(sql)) {
    sentencia.setString(1, "Getafe");
    sentencia.setString(2, "Madrid");
    System.out.println(sentencia.executeUpdate());
}

[Java] ¿Qué pasa con los cambios al salir de este bloque sin errores?
try (Connection conexion = abrirConexion()) {
    conexion.setAutoCommit(false);
    // dos executeUpdate que funcionan bien
}

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.