endpoint.pe

Formularios de dirección en el Perú: cómo armarlos bien

Cómo diseñar un formulario de dirección para el Perú: selects en cascada de departamento, provincia y distrito, búsqueda por nombre y guardado del ubigeo.

eEquipo endpoint.pe•25 jul. 20269 min de lectura

El formulario de dirección es el que más datos sucios genera en cualquier sistema peruano. Un solo campo "Dirección" recibe cosas como "Av. Arequipa 1234, Lince", "av arequipa 1234 lima", "Arequipa 1234 - Lince - Lima - Lima" y "mi casa, frente al parque". Para una persona son lo mismo; para el courier, la factura electrónica y el reporte de ventas por distrito son cuatro cosas distintas, y tres de ellas inservibles.

La solución no es un formulario enorme sino uno bien estructurado: departamento, provincia y distrito elegidos de listas oficiales y guardados como ubigeo, más una línea de dirección y una referencia. Aquí vas a ver cómo armar los selects en cascada, cuándo reemplazarlos por una búsqueda por nombre, por qué guardar el código y no el texto, cómo decidir entre dirección libre y estructurada, y un ejemplo en JavaScript con su backend.


El problema del campo de texto libre

Un campo de texto libre para el distrito tiene tres problemas que no se arreglan con validaciones de formato.

El primero es la ambigüedad: "Lima" puede ser el departamento, la provincia o el distrito del Cercado, y "San Juan" existe en muchas provincias del país. Sin más contexto no hay forma de saber a cuál se refiere el usuario. El segundo son las variantes: tildes, mayúsculas, abreviaturas, errores de tipeo. Cada variante es un valor distinto en tu base de datos y tus reportes por distrito se parten en decenas de filas. El tercero es la inconsistencia jerárquica: nada impide que el usuario escriba distrito "Miraflores" con provincia "Arequipa", y como Miraflores existe en Lima y también en Arequipa, solo el código dice de cuál hablas.

El ubigeo resuelve los tres de golpe, porque el usuario no escribe el lugar: lo elige de una lista cerrada que ya viene con jerarquía. Si no conoces la estructura del código, lee qué es el ubigeo; lo esencial es que son seis dígitos, dos de departamento, dos de provincia y dos de distrito.

Selects en cascada: departamento, provincia y distrito

Es el patrón clásico y sigue siendo el más claro para la mayoría de usuarios: tres listas desplegables donde cada una depende de la anterior.

  1. El usuario elige el departamento (25 opciones, incluido el Callao).
  2. El sistema carga las provincias de ese departamento (desde una sola, en el Callao, hasta unas veinte, en Áncash).
  3. El sistema carga los distritos de esa provincia y el usuario elige uno.

Tres detalles de implementación que marcan la diferencia:

  • Las listas salen de tu propia base de datos, no de una llamada externa en cada cambio de select. La tabla de ubigeos es pequeña, cambia pocas veces al año y puedes servirla desde tu backend en milisegundos, incluso cachearla en el navegador al cargar la página.
  • Ordena alfabéticamente por nombre, no por código. El orden de los códigos no le dice nada al usuario.
  • Al cambiar un nivel, limpia los inferiores. Si el usuario cambia de departamento, la provincia y el distrito elegidos antes dejan de tener sentido y deben vaciarse; si no, terminas guardando un distrito que no pertenece a la provincia.

Si el formulario es para clientes con RUC, puedes ahorrarle los tres clics: consulta el RUC, toma el ubigeo y la dirección del domicilio fiscal que devuelve la API de consulta RUC y preselecciona los tres niveles. El usuario solo confirma o corrige. Es el mismo patrón de autocompletar nombres con el DNI, aplicado a la dirección.

Búsqueda por nombre: una sola caja

Los tres selects funcionan bien en escritorio y con usuarios ocasionales. En móvil, o cuando alguien registra direcciones todo el día (un operador de call center, un vendedor de campo), tres desplegables con cientos de opciones son lentos. La alternativa es una sola caja de búsqueda: el usuario escribe "miraf" y ve una lista de coincidencias con su ruta completa:

  • Miraflores, Lima, Lima
  • Miraflores, Arequipa, Arequipa

Reglas para que funcione:

  • Siempre muestra la ruta completa (distrito, provincia, departamento). Es la única forma de desambiguar homónimos.
  • Busca sin tildes ni mayúsculas: normaliza tanto el texto que escribe el usuario como los nombres de tu tabla.
  • Busca en los tres niveles: si el usuario escribe "huaral", debe encontrar la provincia y sus distritos, no solo el distrito que se llama igual.
  • Espera a que el usuario deje de escribir (un debounce de unos 200 a 300 milisegundos) antes de consultar al backend, y descarta la respuesta de una búsqueda anterior si llega después de la nueva.
  • Deja un camino alterno: un enlace "elegir de la lista" que muestre los tres selects para quien no encuentra lo que busca.

Las dos formas pueden convivir: búsqueda como entrada principal y selects como respaldo. Lo que no debe convivir es la búsqueda con un campo de texto libre que se guarda tal cual.

Guarda el código, no el texto

Esta es la regla más importante del artículo. Lo que se guarda en la base de datos es el ubigeo de seis dígitos, como texto, en una sola columna. Los nombres del departamento, la provincia y el distrito se resuelven al mostrar, consultando la tabla.

Guardar solo el código tiene consecuencias prácticas buenas:

  • Un solo campo que validar. En el backend compruebas que el código tenga seis dígitos y que exista en tu tabla. Si quieres una segunda validación independiente de tu tabla, la API de ubigeo de endpoint.pe devuelve los nombres para un código o un 404 si no existe.
  • Reportes y filtros triviales. Los clientes de Arequipa son los que tienen un código que empieza con 04; los de Lima Metropolitana, con 1501. Para los matices de Lima, mira Lima Metropolitana, Callao y Lima provincias en el ubigeo.
  • Cambios de nombre sin migraciones. Si un distrito cambia de nombre, actualizas una fila de la tabla y todas las direcciones se muestran bien.
  • Integración directa con facturación. El comprobante electrónico y la guía de remisión piden el ubigeo; lo tienes listo sin transformar nada.

Dos cuidados. Guarda el código como texto (CHAR(6) o equivalente), nunca como entero, porque 010101 perdería su cero inicial. Y asegúrate de que tu tabla sea la del INEI, que es la que usa SUNAT; si recibes códigos de otra fuente, revisa ubigeo INEI vs ubigeo RENIEC antes de cruzar nada.

Si además quieres conservar los nombres (por ejemplo, para que una factura ya emitida mantenga el texto exacto que se imprimió), hazlo como copia de solo lectura junto al documento, no como fuente de verdad de la dirección del cliente.

Dirección libre o dirección estructurada

Debajo del ubigeo viene la dirección propiamente dicha, y aquí hay dos escuelas.

Dirección libre: una sola línea de texto ("Av. Arequipa 1234 Of. 501") más una referencia opcional ("frente al parque"). Es lo que piden los comprobantes electrónicos y las guías de remisión: una línea de dirección y el ubigeo. Es rápida de llenar y difícil de hacer mal.

Dirección estructurada: campos separados para tipo de vía (avenida, jirón, calle, pasaje), nombre de vía, número, interior, urbanización o asentamiento, manzana y lote. Es lo que necesita un sistema logístico que calcula rutas, o una entidad financiera que valida domicilios. Es más precisa y mucho más tediosa de llenar; los usuarios la abandonan si no es imprescindible.

Para la mayoría de sistemas de facturación, e-commerce y CRM, la recomendación es: ubigeo obligatorio, línea de dirección obligatoria, referencia opcional, y campos estructurados solo si tu operación los va a usar de verdad. Si los agregas, genera la línea de dirección automáticamente a partir de ellos para el comprobante, y no obligues al usuario a escribir lo mismo dos veces.

Ejemplo en JavaScript

El ejemplo tiene dos partes. En el navegador, tres elementos select con los ids departamento, provincia y distrito, y un campo oculto ubigeo que es lo único que viaja al servidor. En el backend, rutas que sirven las listas desde tu tabla y una que valida el código antes de guardar.

Front-end, sin frameworks para que lo adaptes a cualquiera:

const sel = {
  departamento: document.getElementById('departamento'),
  provincia: document.getElementById('provincia'),
  distrito: document.getElementById('distrito'),
};
const campoUbigeo = document.getElementById('ubigeo');

async function cargar(select, url, placeholder) {
  select.innerHTML = '';
  select.appendChild(new Option(placeholder, ''));
  const res = await fetch(url);
  const filas = await res.json(); // [{ codigo: '15', nombre: 'LIMA' }, ...]
  for (const f of filas) select.appendChild(new Option(f.nombre, f.codigo));
  select.disabled = false;
}

function limpiar(...selects) {
  for (const s of selects) { s.innerHTML = ''; s.disabled = true; }
  campoUbigeo.value = '';
}

cargar(sel.departamento, '/api/ubigeos/departamentos', 'Departamento');

sel.departamento.addEventListener('change', () => {
  limpiar(sel.provincia, sel.distrito);
  if (sel.departamento.value) {
    cargar(sel.provincia, `/api/ubigeos/provincias?departamento=${sel.departamento.value}`, 'Provincia');
  }
});

sel.provincia.addEventListener('change', () => {
  limpiar(sel.distrito);
  if (sel.provincia.value) {
    cargar(sel.distrito, `/api/ubigeos/distritos?provincia=${sel.provincia.value}`, 'Distrito');
  }
});

sel.distrito.addEventListener('change', () => {
  campoUbigeo.value = sel.distrito.value; // el código de seis dígitos, por ejemplo '150131'
});

Backend con Node.js y Express; la lógica es idéntica en PHP, Python o cualquier otro lenguaje. Los códigos de provincia son de cuatro dígitos (1501) y los de distrito de seis, así que filtrar por prefijo basta:

import express from 'express';
const app = express();
app.use(express.json());

// Listas desde tu propia tabla de ubigeos (codificación INEI)
app.get('/api/ubigeos/departamentos', async (req, res) => {
  res.json(await db.query('SELECT codigo, nombre FROM ubigeo_departamentos ORDER BY nombre'));
});

app.get('/api/ubigeos/provincias', async (req, res) => {
  const prefijo = String(req.query.departamento ?? ''); // '15'
  if (!/^\d{2}$/.test(prefijo)) return res.status(400).end();
  res.json(await db.query('SELECT codigo, nombre FROM ubigeo_provincias WHERE codigo LIKE $1 ORDER BY nombre', [prefijo + '%']));
});

app.get('/api/ubigeos/distritos', async (req, res) => {
  const prefijo = String(req.query.provincia ?? ''); // '1501'
  if (!/^\d{4}$/.test(prefijo)) return res.status(400).end();
  res.json(await db.query('SELECT codigo, nombre FROM ubigeo_distritos WHERE codigo LIKE $1 ORDER BY nombre', [prefijo + '%']));
});

// Al guardar: valida formato, existencia y dirección mínima
app.post('/api/clientes', async (req, res) => {
  const { ubigeo, direccion, referencia } = req.body;
  if (!/^\d{6}$/.test(ubigeo)) return res.status(422).json({ error: 'ubigeo_invalido' });

  const existe = await db.query('SELECT 1 FROM ubigeo_distritos WHERE codigo = $1', [ubigeo]);
  if (existe.length === 0) return res.status(422).json({ error: 'ubigeo_no_existe' });

  if (!direccion || direccion.trim().length < 5) return res.status(422).json({ error: 'direccion_incompleta' });

  await db.query(
    'INSERT INTO clientes (ubigeo, direccion, referencia) VALUES ($1, $2, $3)',
    [ubigeo, direccion.trim(), referencia ?? null],
  );
  res.status(201).end();
});

Tres cosas a notar. La validación del backend no confía en el front-end: aunque los selects solo permitan códigos válidos, cualquiera puede enviar un POST a mano. El ubigeo viaja y se guarda como texto. Y el nombre nunca se guarda: si necesitas verificar un código contra una fuente externa, por ejemplo al importar clientes desde un Excel, la API de ubigeo te devuelve los nombres o un 404, y las consultas sin resultado no consumen créditos.

En resumen

  • El texto libre para departamento, provincia y distrito genera ambigüedad, variantes y jerarquías imposibles; el ubigeo elimina los tres problemas.
  • Usa selects en cascada servidos desde tu propia tabla, ordenados por nombre y que se limpien al cambiar el nivel superior.
  • Para móvil o uso intensivo, ofrece una búsqueda por nombre que muestre siempre la ruta completa del distrito.
  • Guarda solo el código, como texto de seis caracteres, en la codificación del INEI; resuelve los nombres al mostrar.
  • Línea de dirección más referencia cubre facturación y envíos; agrega campos estructurados solo si tu operación los usa.
  • Valida en el backend formato, existencia y dirección mínima, sin confiar en el navegador.

Preguntas frecuentes

¿Debo pedir la dirección completa o basta con el distrito?

Depende de lo que hagas con ella. Para un comprobante electrónico o una guía de remisión necesitas la línea de dirección y el ubigeo. Para un envío necesitas además una referencia. Para segmentar clientes por zona, con el ubigeo basta.

¿Cómo manejo a un cliente de un distrito que no aparece en mi lista?

Casi siempre es un distrito creado recientemente que tu tabla no tiene. Permite que el usuario lo reporte y actualiza la tabla desde la fuente oficial del INEI. Mientras tanto, no lo obligues a elegir un distrito equivocado: mejor una dirección pendiente de revisión que una falsa.

¿Puedo preseleccionar la dirección desde el RUC o el DNI?

Desde el RUC sí: la ficha incluye el domicilio fiscal con su ubigeo y puedes preseleccionar los tres niveles para que el usuario confirme. Desde el DNI no: la consulta devuelve nombres y apellidos, no el domicilio, así que la dirección de una persona natural siempre la ingresa ella.

¿Vale la pena validar el ubigeo con una API si ya tengo la tabla?

Para el formulario del día a día, tu tabla basta. La validación externa tiene sentido al importar datos de terceros o cuando sospechas que tu tabla está desactualizada: una consulta por código te confirma que existe y a qué nombres corresponde.

Deja de copiar datos a mano. Automatízalo hoy mismo

Crea tu cuenta, copia tu token y empieza hoy con 150 consultas gratis al mes.