Escribes un programa en Java que calcula el IVA de un precio, lo pruebas con 10 y funciona perfecto. Se lo enseñas a un amigo, escribe 12,50 con coma (como se escribe en España) y el programa se cae con un montón de líneas rojas que no entiendes. Aquí es donde la mayoría se atasca la primera vez que su código sale de su propio ordenador: funciona con tus datos y revienta con los de los demás.
Esas líneas rojas son una excepción, y Java te da herramientas para decidir qué hacer cuando aparece una, en vez de dejar que el programa muera. Este post sigue el de interfaces y clases abstractas, donde quedó pendiente qué pasa cuando algo falla dentro de un método como mover(). Necesitas saber lo básico de clases y métodos; si vienes de cero, empieza por tu primer programa en Java.
Todo el código está probado con JDK 21 y funciona igual en Java 17 y en Java 25 (la última versión LTS). Java 27 salió el 15 de septiembre de 2026, pero no es LTS y nada de lo que vas a ver aquí cambia.
Qué es una excepción y cómo se lee
Piensa en cualquier web donde has escrito algo mal en un campo y te ha salido un aviso en vez de romperse. Alguien programó qué hacer en ese caso. Cuando tu código Java llega a una situación en la que no puede continuar (convertir texto en número, abrir un archivo que no existe, dividir entre cero), lanza una excepción: un objeto que dice qué ha fallado y dónde. Si nadie la recoge, el programa se detiene.
Vamos a provocar una a propósito. Este es el cálculo de antes, con el precio tal cual lo escribiría una persona en España:
public class Factura {
public static void main(String[] args) {
String texto = "12,50";
double precio = Double.parseDouble(texto);
System.out.println("Precio: " + precio);
}
}
Al ejecutarlo, Java no imprime el precio. Imprime esto:
Exception in thread "main" java.lang.NumberFormatException: For input string: "12,50"
at java.base/jdk.internal.math.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2054)
at java.base/jdk.internal.math.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.base/java.lang.Double.parseDouble(Double.java:792)
at Factura.main(Factura.java:4)
La primera línea es la que importa: el tipo de excepción (NumberFormatException) y el motivo (For input string: "12,50", o sea, no sé convertir ese texto en número). Las líneas que empiezan por at son la ruta que ha seguido el programa hasta romperse, y la que te interesa es la primera que lleva el nombre de tu clase: Factura.main(Factura.java:4) te dice que fue en la línea 4 de tu archivo. Las de java.base son el interior de Java y cambian de una versión a otra.
❌ Error común: leer el error de abajo hacia arriba
Esto me costó pillarlo: durante semanas me quedaba mirando las líneas internas de java.base sin entender nada. Lee la primera línea para saber qué pasó y busca después la primera línea con el nombre de tu clase para saber dónde. Con eso resuelves la mayoría de errores sin copiar nada a ningún sitio.
try y catch: la red del trapecista
Un trapecista hace su número exactamente igual con red que sin ella. La red no hace nada mientras todo va bien; solo entra en juego si se cae, lo recoge y el espectáculo continúa. Java tiene justo eso: metes dentro de un bloque try el código que puede fallar, y en un bloque catch escribes qué hacer si falla.
Fíjate en que ahora usamos BigDecimal en vez de double. Con dinero conviene hacerlo así, porque double acumula errores de redondeo (0.1 + 0.2 da 0.30000000000000004) y en una factura se nota:
import java.math.BigDecimal;
import java.math.RoundingMode;
public class Factura {
public static void main(String[] args) {
String texto = "12,50";
try {
BigDecimal base = new BigDecimal(texto);
BigDecimal total = base.multiply(new BigDecimal("1.21"))
.setScale(2, RoundingMode.HALF_UP);
System.out.println("Total con IVA: " + total + " €");
} catch (NumberFormatException e) {
System.out.println("No entiendo ese precio: " + texto);
}
System.out.println("El programa sigue vivo");
}
}
No entiendo ese precio: 12,50
El programa sigue vivo
El programa ya no se cae. La línea "El programa sigue vivo" se imprime porque, una vez recogida la excepción, Java sigue ejecutando lo que viene después del catch. Y sí, con 12,50 sigue entrando en el catch: BigDecimal tampoco acepta comas. Lo arreglaremos en el mini proyecto de más abajo; de momento lo importante es ver cómo se comporta la red.
❌ Error común: el catch vacío
Escribir catch (Exception e) { } con las llaves vacías hace que el error desaparezca sin dejar rastro. El programa no se cae, pero tampoco hace lo que debía y no sabes por qué. Como mínimo, imprime e.getMessage() o devuelve un valor que indique que algo falló. Un fallo que se ve se arregla, uno silenciado te lo encuentras a las dos semanas.
Otro tropiezo clásico es el orden de los catch. Si pones primero catch (Exception e) y después catch (NumberFormatException e), el compilador te frena con exception NumberFormatException has already been caught. Es lógico: Exception ya atrapa todo, así que el segundo bloque nunca se ejecutaría. La regla es poner siempre primero la excepción más concreta y al final la más general.
Varias excepciones en el mismo try
Un mismo bloque puede fallar de formas distintas. Imagina un pedido con una lista de productos y un pago en plazos. Puedes pedir un producto que no existe en la lista o dividir entre cero plazos, y quieres tratar los dos casos con el mismo mensaje. Con el símbolo | los agrupas en un solo catch:
import java.util.ArrayList;
import java.util.List;
public class Pedido {
public static void main(String[] args) {
List<String> productos = new ArrayList<>();
productos.add("Teclado");
productos.add("Ratón");
int plazos = 0;
double total = 120.0;
try {
System.out.println("Tercer producto: " + productos.get(2));
System.out.println("Cuota: " + (int) total / plazos);
} catch (IndexOutOfBoundsException | ArithmeticException e) {
System.out.println("Algo no cuadra: " + e.getMessage());
}
}
}
Algo no cuadra: Index 2 out of bounds for length 2
Ojo a un detalle que despista: en cuanto una línea del try lanza una excepción, Java salta directamente al catch y no ejecuta las líneas siguientes. Aquí falla ya el get(2) (la lista solo tiene dos elementos, posiciones 0 y 1), así que la división entre cero ni se llega a intentar. Si quieres saber más de listas, tienes el post de ArrayList y HashMap.
Las excepciones que más vas a encontrar al empezar son pocas: NullPointerException (usar una variable que vale null), IndexOutOfBoundsException (pedir una posición que no existe), NumberFormatException (texto que no es un número), ArithmeticException (dividir enteros entre cero) y ClassCastException (convertir un objeto a un tipo que no es).
❌ Error común: arreglar un NullPointerException con un catch
Si te sale un NullPointerException, la tentación es envolver todo en un try. No lo hagas: ese error casi siempre significa que te has olvidado de inicializar algo o de comprobar si el valor existe. Se arregla con un if (valor != null) o inicializando bien la variable, no escondiéndolo. Los catch son para fallos que esperas que ocurran (un usuario que escribe mal, un archivo que no está), no para tapar errores de tu propio código.
finally y try-with-resources: cierra lo que abres
Cuando abres un archivo, una conexión a una base de datos o cualquier recurso similar, tienes que cerrarlo al terminar, haya ido bien o mal. Es como apagar la luz al salir de una habitación: no importa si saliste tranquilo o a toda prisa, la luz se apaga igual. Para eso existe finally, un bloque que Java ejecuta siempre al final, salte o no una excepción.
Antes de ver cómo se escribe, un detalle importante: hoy casi nunca lo vas a escribir a mano para cerrar recursos. Desde Java 7 existe try-with-resources, que cierra el recurso automáticamente al salir del bloque. Se escribe poniendo el recurso entre paréntesis justo después de try:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class LectorPrecios {
public static void main(String[] args) {
try (BufferedReader lector = new BufferedReader(new FileReader("precios.txt"))) {
String linea;
while ((linea = lector.readLine()) != null) {
System.out.println("Leído: " + linea);
}
} catch (IOException e) {
System.out.println("No se pudo leer precios.txt: " + e.getMessage());
} finally {
System.out.println("Fin de la lectura");
}
}
}
No se pudo leer precios.txt: precios.txt (No such file or directory)
Fin de la lectura
Dos cosas a notar (el texto No such file or directory es el de Linux y macOS; en Windows sale en español, pero significa lo mismo). Primera: BufferedReader se cierra solo, sin que escribas ningún close(). Segunda: el finally se ha ejecutado aunque haya saltado una excepción, y también se ejecuta si todo va bien. Incluso se ejecuta si dentro del try hay un return: Java ejecuta el finally justo antes de salir del método.
❌ Error común: "no encuentra el archivo" cuando el archivo está ahí
Si ves No such file or directory y estás seguro de que precios.txt existe, casi seguro lo has dejado en una carpeta distinta a la que Java usa como punto de partida. En IntelliJ, una ruta relativa como "precios.txt" se busca en la raíz del proyecto, no junto al archivo .java. Y otro clásico en Windows: si no cierras un archivo, el sistema no te deja borrarlo ni renombrarlo mientras el programa siga en marcha.
Excepciones comprobadas y no comprobadas
Si en el ejemplo anterior quitas el try y dejas solo new FileReader("precios.txt") dentro de main, el programa ni siquiera compila. El error es unreported exception FileNotFoundException; must be caught or declared to be thrown. Este es el momento en que mucha gente se pierde, porque con NumberFormatException nadie te obligaba a nada y aquí sí.
La razón es que Java divide las excepciones en dos familias. Las comprobadas (checked), como IOException y todas sus hijas, representan cosas que pueden fallar aunque tu código esté bien escrito: el disco, la red, un archivo ajeno. El compilador te obliga a decidir qué hacer con ellas: o las capturas con try/catch, o las pasas hacia arriba añadiendo throws a la firma del método. Las no comprobadas (unchecked) heredan de RuntimeException, como NumberFormatException o NullPointerException, y suelen indicar un fallo de programación o de datos de entrada; el compilador no te obliga a nada.
Por encima de todas está Throwable, con dos ramas: Exception (lo que tú vas a capturar) y Error (problemas graves de la máquina virtual, como OutOfMemoryError, que casi nunca se capturan).
Lanzar tus propias excepciones
Hasta ahora has capturado errores que lanzaba Java. Pero tu propio código también puede detectar situaciones que no tienen sentido y avisar. Retomemos la cuenta bancaria de el post de POO: si alguien intenta retirar 100 € de una cuenta con 80 €, no basta con imprimir un mensaje por pantalla. El método que retira dinero debería decirle a quien lo llama que no ha podido hacerlo.
Para eso están throw (lanza una excepción en ese punto) y throws (avisa en la firma del método de qué excepciones comprobadas puede lanzar). Y cuando ninguna excepción de Java describe bien tu problema, creas la tuya heredando de Exception:
public class CuentaBancaria {
private double saldo;
public CuentaBancaria(double saldo) {
this.saldo = saldo;
}
public void retirar(double cantidad) throws SaldoInsuficienteException {
if (cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser positiva");
}
if (cantidad > saldo) {
throw new SaldoInsuficienteException(saldo, cantidad);
}
saldo -= cantidad;
}
public double getSaldo() {
return saldo;
}
}
class SaldoInsuficienteException extends Exception {
private final double faltante;
public SaldoInsuficienteException(double saldo, double pedido) {
super("Saldo insuficiente: tienes " + saldo + " € y pides " + pedido + " €");
this.faltante = pedido - saldo;
}
public double getFaltante() {
return faltante;
}
}
class Cajero {
public static void main(String[] args) {
CuentaBancaria cuenta = new CuentaBancaria(80.0);
try {
cuenta.retirar(100.0);
} catch (SaldoInsuficienteException e) {
System.out.println(e.getMessage());
System.out.println("Te faltan " + e.getFaltante() + " €");
}
}
}
Saldo insuficiente: tienes 80.0 € y pides 100.0 €
Te faltan 20.0 €
Hay tres ideas en ese código. IllegalArgumentException es una excepción de Java que sirve para "me has pasado un argumento que no vale", y no hace falta ni declararla con throws porque es no comprobada. SaldoInsuficienteException es nuestra, comprobada, y por eso retirar() la declara y Cajero está obligado a capturarla. Y como la hemos creado como clase con sus propios datos, el catch puede pedirle getFaltante() y mostrar cuánto dinero falta exactamente.
Si usas IntelliJ, cada clase va en su propio archivo (SaldoInsuficienteException.java, Cajero.java...). Aquí las hemos juntado solo para que el ejemplo se lea de un vistazo.
❌ Error común: lanzar Exception a secas o con un mensaje vacío
Escribir throw new Exception() sin mensaje es como dejar una nota que dice "ha pasado algo". Quien lo capture no sabe qué hacer. Pon siempre un mensaje que diga qué pasó y con qué valores (aquí, el saldo y la cantidad pedida) y usa el tipo más específico posible. Dentro de dos meses, ese mensaje lo vas a leer tú.
Mini proyecto: calculadora de IVA a prueba de despistes
Juntemos todo en un programa pequeño que pida un precio por teclado, acepte la coma española, rechace lo que no sea un número o sea negativo, y calcule el IVA al 21%. Si el usuario se equivoca, le vuelve a preguntar en vez de caerse:
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Scanner;
public class CalculadoraIva {
private static final BigDecimal IVA = new BigDecimal("0.21");
public static void main(String[] args) {
Scanner teclado = new Scanner(System.in);
BigDecimal base = null;
while (base == null) {
System.out.print("Precio sin IVA en euros: ");
String texto = teclado.nextLine().trim().replace(',', '.');
try {
base = new BigDecimal(texto);
if (base.signum() < 0) {
throw new IllegalArgumentException("El precio no puede ser negativo");
}
} catch (NumberFormatException e) {
System.out.println("Eso no es un número. Prueba con 12,50");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
base = null;
}
}
BigDecimal iva = base.multiply(IVA).setScale(2, RoundingMode.HALF_UP);
BigDecimal total = base.add(iva);
System.out.println("IVA (21%): " + iva + " €");
System.out.println("Total: " + total + " €");
}
}
Precio sin IVA en euros: abc
Eso no es un número. Prueba con 12,50
Precio sin IVA en euros: -3
El precio no puede ser negativo
Precio sin IVA en euros: 12,50
IVA (21%): 2.63 €
Total: 15.13 €
El truco está en el bucle while (base == null): mientras no tengamos un precio válido, volvemos a preguntar. El replace(',', '.') convierte la coma española en el punto que entiende BigDecimal. Y fíjate en el orden de los dos catch: NumberFormatException hereda de IllegalArgumentException, así que la concreta va primero. Si los pones al revés, el compilador se queja igual que antes.
Un detalle de redondeo: 12,50 € por 0,21 da exactamente 2,625 €, y con RoundingMode.HALF_UP se redondea hacia arriba a 2,63 €, que es lo que esperarías en una factura.
Ejercicio: valida una edad
Escribe un método leerEdad(String texto) que devuelva un int. Si el texto no es un número, tiene que lanzar una IllegalArgumentException con un mensaje claro. Si es un número pero no está entre 0 y 120, también. Prueba con "34", "abc" y "200" desde un main que capture los errores y los imprima.
Ver la solución (inténtalo tú primero)
public class Ejercicio {
static int leerEdad(String texto) {
try {
int edad = Integer.parseInt(texto.trim());
if (edad < 0 || edad > 120) {
throw new IllegalArgumentException("Edad fuera de rango: " + edad);
}
return edad;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("No es un número: " + texto, e);
}
}
public static void main(String[] args) {
String[] entradas = {"34", "abc", "200"};
for (String entrada : entradas) {
try {
System.out.println("Edad válida: " + leerEdad(entrada));
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
}
}
}
Edad válida: 34
Error: No es un número: abc
Error: Edad fuera de rango: 200
Mira la línea throw new IllegalArgumentException("No es un número: " + texto, e). Ese segundo parámetro e guarda la excepción original como causa. Si el error llega sin capturar, en la consola verás una línea Caused by: con el fallo de origen, lo que te ahorra horas de buscar de dónde venía.
Qué toca ahora
Vuelve al Patinete que hiciste en el ejercicio de interfaces. Ahora mismo, cuando la batería no llega, el método mover() imprime un aviso y sale con un return. Cámbialo: crea una BateriaAgotadaException propia que guarde cuánta batería quedaba, lánzala desde mover() y captúrala en el Main para mostrar al usuario cuánta batería quedaba y cuántos metros no pudo recorrer (el consumo es de 2 puntos por metro). Tendrás que tocar la interfaz Movible para que mover() declare el throws, y ahí verás con tus propios ojos por qué las excepciones comprobadas afectan a todo el diseño.
Cuando lo tengas funcionando, el paso siguiente natural es comprobarlo con tests automáticos en JUnit, que permite verificar que tu método lanza justo la excepción que esperas.