Hey devs, me hicieron una consulta y como es algo muy común en sistemas, dije hay que hacerle un artículo 🙌
Así que en este artículo veremos cómo se maneja esto de forma estándar en la industria moderna, usando .NET + EF Core (Code First).
Cuál es el desafío al manejar ubigeos?
Un ubigeo es una jerarquía:
- Tiene padres
- Tiene hijos
- Puede tener varios niveles
- Es prácticamente estático
- Se consulta mucho, pero casi no se modifica
Esto define directamente la solución: Una sola tabla, autorreferenciada, con jerarquía por ParentId.
La base tras esta estrategia que te planteo es el Adjacency List (Lista de Adyacencia) es un patrón de diseño para representar estructuras jerárquicas (como árboles) en una base de datos relacional. En este patrón, cada fila en la tabla contiene una referencia a su padre.
Ojo, el Adjacency List y es el más usado para jerarquías simples como ubigeos.
La estrategia estándar en la industria
El ubigeo es un caso clásico en sistemas empresariales:
país → departamento → provincia → distrito.
Aunque parece simple, es muy común ver malas, o al menos discutibles, decisiones de diseño como:
- Una tabla por nivel
- lógica compleja en SQL
- modelos rígidos difíciles de extender
A continuación te presento cómo quedaría tu única tabla...
Diseño de base de datos
Tabla única: Ubigeo

Por si quieres probarlo directamente (ya que después lo haremos con EF) aquí está el SQL para crearla:
CREATE TABLE Ubigeo (
Id INT IDENTITY(1,1) PRIMARY KEY,
Code VARCHAR(6) NOT NULL,
Name NVARCHAR(100) NOT NULL,
Level INT NOT NULL,
ParentId INT NULL,
CONSTRAINT FK_Ubigeo_Parent FOREIGN KEY (ParentId)
REFERENCES Ubigeo(Id)
);
Ahora vamos a añadirle datos:
-- NIVEL 1: PAÍS
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('PE', 'Perú', 1, NULL);
-- NIVEL 2: DEPARTAMENTOS
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('01', 'Amazonas', 2, 1), -- ParentId = 1 (Perú)
('02', 'Áncash', 2, 1),
('03', 'Apurímac', 2, 1),
('04', 'Arequipa', 2, 1),
('15', 'Lima', 2, 1);
-- NIVEL 3: PROVINCIAS
-- Amazonas (ParentId = 2)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('0101', 'Chachapoyas', 3, 2), -- ParentId = 2 (Amazonas)
('0102', 'Bagua', 3, 2);
-- Áncash (ParentId = 3)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('0201', 'Huaraz', 3, 3), -- ParentId = 3 (Áncash)
('0202', 'Aija', 3, 3);
-- Apurímac (ParentId = 4)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('0301', 'Abancay', 3, 4), -- ParentId = 4 (Apurímac)
('0302', 'Andahuaylas', 3, 4);
-- Arequipa (ParentId = 5)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('0401', 'Arequipa', 3, 5), -- ParentId = 5 (Arequipa)
('0402', 'Camaná', 3, 5);
-- Lima (ParentId = 6)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('1501', 'Lima', 3, 6), -- ParentId = 6 (Lima departamento)
('1502', 'Huaura', 3, 6);
-- NIVEL 4: DISTRITOS
-- Chachapoyas, Amazonas (ParentId = 7)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('010101', 'Chachapoyas', 4, 7), -- ParentId = 7 (Chachapoyas)
('010102', 'Asunción', 4, 7),
('010103', 'Balsas', 4, 7),
('010104', 'Cheto', 4, 7);
-- Bagua, Amazonas (ParentId = 8)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('010201', 'Bagua', 4, 8), -- ParentId = 8 (Bagua)
('010202', 'Aramango', 4, 8),
('010203', 'Copallín', 4, 8);
-- Huaraz, Áncash (ParentId = 9)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('020101', 'Huaraz', 4, 9), -- ParentId = 9 (Huaraz)
('020102', 'Cochabamba', 4, 9),
('020103', 'Colcabamba', 4, 9);
-- Aija, Áncash (ParentId = 10)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('020201', 'Aija', 4, 10), -- ParentId = 10 (Aija)
('020202', 'Coris', 4, 10);
-- Lima, Lima (ParentId = 13)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('150101', 'Lima', 4, 13), -- ParentId = 13 (Lima provincia)
('150102', 'Ancón', 4, 13),
('150103', 'Ate', 4, 13),
('150104', 'Barranco', 4, 13),
('150105', 'Breña', 4, 13),
('150106', 'Carabayllo', 4, 13),
('150107', 'Comas', 4, 13),
('150108', 'Chorrillos', 4, 13);
-- Huaura, Lima (ParentId = 14)
INSERT INTO Ubigeo (Code, Name, Level, ParentId) VALUES
('150201', 'Huacho', 4, 14), -- ParentId = 14 (Huaura)
('150202', 'Ámbar', 4, 14),
('150203', 'Caleta de Carquín', 4, 14),
('150204', 'Checras', 4, 14);
Entonces tus datos lucen así:

Así que todo vive en una sola tabla y la jerarquía se construye por ParentId.
Código con EF Code First
Desde el punto de vista del código con EF Code first qué tendrías que hacer? Ahora te lo cuento...
Entidad de dominio:
public class Ubigeo
{
public int Id { get; set; }
public string Code { get; set; } = default!;
public string Name { get; set; } = default!;
public UbigeoLevel Level { get; set; }
public int? ParentId { get; set; }
public Ubigeo? Parent { get; set; }
public ICollection<Ubigeo> Children { get; set; } = new List<Ubigeo>();
}
Y en la configuración para su migración (si estás usando Configurations pattern) tendrías algo así:
public class UbigeoConfiguration : IEntityTypeConfiguration<Ubigeo>
{
public void Configure(EntityTypeBuilder<Ubigeo> builder)
{
builder.ToTable("Ubigeo");
builder.HasKey(x => x.Id);
builder.Property(x => x.Code)
.IsRequired()
.HasMaxLength(6);
builder.Property(x => x.Name)
.IsRequired()
.HasMaxLength(150);
builder.Property(x => x.Level)
.IsRequired();
builder.HasOne(x => x.Parent)
.WithMany(x => x.Children)
.HasForeignKey(x => x.ParentId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => x.ParentId);
builder.HasIndex(x => new { x.Level, x.Code }).IsUnique();
}
}
Así podrías hacer tus consultas típicas:
- Obtener hijos por nivel
- Obtener el árbol completo (hasta distritos)
De esta manera:

O esta para obtener todo el "árbol" de datos:

Si quieres ir un paso más allá...
Además de todo esto, una práctica recomendada hoy por hoy en la industria es aplicar caché ya que:
- El ubigeo no cambia
- se consulta relativamente seguido
- No debe recalcularse
En conclusión estimado dev, algunos de los enfoques que no son recomendados hoy en día (y su explicación) son:
- Una tabla por nivel, si bien antes era común y además aplica la teoría rigurosamente, precisamente esa es su debilidad porque lo hace muy rígido y poco escalable.
- Procedimientos almacenados recursivos tampoco ya que añaden complejidad innecesaria.
- Lógica jerárquica en SQL es difícil de mantener y mueve lógica a la base de datos de manera innecesaria.
Se podría decir que una regla no escrita en la ingeniería de software es: Si tienes una jerarquía simple y datos estáticos, entonces mete todo en una sola tabla autoreferenciada.
Este enfoque es el que encontrarás en sistemas bancarios, ERPs, gobiernos y grandes APIs, y lo he podido comprobar yo mismo 😜.
Finalmente, he creado un repo donde implemento este artículo y está disponible aquí, dale estrella y compártelo junto con este artículo a todo tu equipo de ingeniería crack! 🎉🙌
Créditos de imagen: Foto de The Drink Break en Unsplash
