Saltar al contenido

Lenguaje M en Power Query: guía completa para dominar las transformaciones

Guía completa del lenguaje M en Power Query: del código let...in al ETL completo

Escrito por

en

Cada empresa tiene el mismo problema: los datos llegan sucios, dispersos en varios archivos y con formatos inconsistentes. El analista pasa horas limpiando manualmente antes de poder armar un solo reporte.

El lenguaje M de Power Query cambia eso. Una vez que escribís la transformación, se ejecuta sola en cada actualización — sin intervención, sin errores manuales, siempre igual.

Esta guía está organizada por casos reales de empresa, no por teoría abstracta. Cada sección parte de un problema concreto que probablemente ya enfrentaste.


No hemos podido validar tu suscripción.
Tu PDF va en camino — revisá tu correo.

Tu guía de Power Query gratis


¿Qué es el lenguaje M y por qué importa en la empresa?

M (Power Query Formula Language) es el lenguaje que corre detrás de cada paso del editor de Power Query. Cuando filtrás, renombrás una columna o cambiás un tipo desde los menús, Power Query escribe código M por vos. Ese código queda guardado y se puede leer — y modificar — en el Editor avanzado.

Cinco reglas que definen cómo funciona:

  • Es sensible a mayúsculas. Text.Trim funciona; text.trim falla.
  • Estructura let … in. Cada consulta tiene pasos nombrados en let y devuelve el resultado con in.
  • Evaluación perezosa. Solo evalúa lo que necesita para el resultado final — no re-procesa pasos innecesarios.
  • Inmutable. Cada transformación crea una copia; no modifica el dato original.
  • Ecosistema amplio. El mismo M funciona en Power BI, Excel, Dataflows de Fabric, Power Automate y Azure Data Factory.

Aprendelo una vez y lo aplicás en todas las herramientas del ecosistema Microsoft.


La estructura base: let … in

Todo código M vive dentro de un bloque let … in. Los pasos del let se ejecutan en secuencia; el in dice cuál es el resultado final.

Caso: el área de Finanzas recibe un CSV con ventas diarias y necesita cargarlo, limpiar los nombres de vendedor y filtrar solo las ventas aprobadas.

let
    // Paso 1: conectar al archivo
    Origen       = Csv.Document(File.Contents("C:\Reportes\Ventas.csv"),
                       [Delimiter=",", Encoding=65001]),

    // Paso 2: promover la primera fila como encabezados
    Encabezados  = Table.PromoteHeaders(Origen, [PromoteAllScalars=true]),

    // Paso 3: asignar tipos de dato correctos
    Tipos        = Table.TransformColumnTypes(Encabezados, {
                       {"Fecha", type date}, {"Vendedor", type text},
                       {"Monto", Currency.Type}, {"Estado", type text}}),

    // Paso 4: solo ventas aprobadas
    Aprobadas    = Table.SelectRows(Tipos, each [Estado] = "Aprobada")
in
    Aprobadas

Cada paso referencia al anterior. Renombrá los pasos con nombres descriptivos — Aprobadas explica más que "Filas filtradas".


Tipos de datos: definirlos bien evita el 80 % de los errores

El error más frecuente en Power Query es trabajar con columnas sin tipo definido. Una columna de fecha que Power Query lee como text no puede filtrarse por mes. Un monto leído como text no puede sumarse.

Tipo M Cuándo usarlo Ejemplo
type text Nombres, códigos, descripciones "Quito", "C-0042"
type number Cantidades, porcentajes 42, 0.15
Currency.Type Montos monetarios (evita errores de redondeo) 1250.50
type date Fechas sin hora #date(2026, 6, 9)
type datetime Fecha con hora (registros de sistema) #datetime(2026,6,9,14,0,0)
type logical Banderas sí/no, activo/inactivo true, false
null Celda vacía o dato faltante null

Regla práctica: definí los tipos en el segundo o tercer paso de toda consulta, apenas después de cargar los encabezados. Hacerlo tarde multiplica los errores aguas abajo.

Table.TransformColumnTypes(Encabezados, {
    {"Fecha_Factura",  type date},
    {"Codigo_Cliente", type text},
    {"Importe",        Currency.Type},
    {"Pagado",         type logical}
})

Caso 1: Limpiar la base de clientes

Problema real: el CRM exporta nombres de cliente con espacios extra, mezcla de mayúsculas y caracteres de control invisibles. El equipo de ventas no puede cruzar datos porque «EMPRESA S.A. » (con espacio al final) y «Empresa S.A.» se tratan como dos clientes distintos.

Solución en M:

let
    Origen = Excel.Workbook(File.Contents("C:\CRM\Clientes_Export.xlsx"), null, true)
               {[Name="Hoja1"]}[Data],

    Tipos  = Table.TransformColumnTypes(Origen, {
                 {"ID_Cliente", type text}, {"Nombre", type text},
                 {"Ciudad", type text}, {"Segmento", type text}}),

    // Limpiar nombre: quitar espacios, caracteres invisibles y estandarizar mayúsculas
    Limpio = Table.TransformColumns(Tipos, {
                 {"Nombre",  each Text.Proper(Text.Trim(Text.Clean(_))), type text},
                 {"Ciudad",  each Text.Proper(Text.Trim(_)),             type text},
                 {"Segmento",each Text.Upper(Text.Trim(_)),              type text}
             }),

    // Eliminar duplicados por ID (si el CRM exportó el mismo cliente dos veces)
    SinDuplicados = Table.Distinct(Limpio, {"ID_Cliente"})
in
    SinDuplicados

Funciones de texto más usadas:

Text.Trim("  Empresa S.A.  ")      // → "Empresa S.A."      (quita extremos)
Text.Clean("Empresa\n S.A.")       // → "Empresa S.A."      (quita caracteres de control)
Text.Proper("EMPRESA s.a.")        // → "Empresa S.A."      (capitaliza)
Text.Upper("empresa")              // → "EMPRESA"
Text.Lower("EMPRESA")              // → "empresa"
Text.Replace("S.A.","SA")          // → reemplaza
Text.Contains([Nombre], "S.A.")    // → true / false
Text.Length([Nombre])              // → cantidad de caracteres

Truco: Text.Trim no elimina los espacios dobles internos (entre palabras). Si tu fuente los tiene, usá este patrón para colapsarlos: m Text.Combine(List.Select(Text.Split([Nombre], " "), each _ <> ""), " ")


Caso 2: Calcular aging de cartera (días de mora)

Problema real: el área de Cobranzas necesita clasificar cada factura pendiente en tramos de antigüedad (0-30, 31-60, 61-90, más de 90 días) para el reporte mensual de riesgo.

let
    Origen = Csv.Document(File.Contents("C:\Finanzas\Facturas_Pendientes.csv"),
                 [Delimiter=";", Encoding=65001]),

    Tipos  = Table.TransformColumnTypes(
                 Table.PromoteHeaders(Origen, [PromoteAllScalars=true]), {
                     {"Nro_Factura", type text},
                     {"Fecha_Vencimiento", type date},
                     {"Saldo", Currency.Type}}),

    // Calcular días de mora respecto a hoy
    ConDias = Table.AddColumn(Tipos, "Dias_Mora",
                  each Duration.Days(Date.From(DateTime.LocalNow()) - [Fecha_Vencimiento]),
                  Int64.Type),

    // Clasificar en tramos de aging
    ConTramo = Table.AddColumn(ConDias, "Tramo",
                   each if [Dias_Mora] <= 0  then "Al día"
                        else if [Dias_Mora] <= 30 then "0-30 días"
                        else if [Dias_Mora] <= 60 then "31-60 días"
                        else if [Dias_Mora] <= 90 then "61-90 días"
                        else "> 90 días",
                   type text),

    // Total por tramo
    Resumen = Table.Group(ConTramo, {"Tramo"}, {
                  {"Facturas",   each Table.RowCount(_),   Int64.Type},
                  {"Saldo_Total",each List.Sum([Saldo]),   Currency.Type}
              })
in
    Resumen

Funciones de fecha más usadas:

Date.From(DateTime.LocalNow())        // fecha de hoy
Duration.Days(fecha1 - fecha2)        // diferencia en días
Date.Year([Fecha])                    // año
Date.Month([Fecha])                   // número de mes (1-12)
Date.MonthName([Fecha])               // nombre del mes ("junio")
Date.QuarterOfYear([Fecha])           // trimestre (1-4)
Date.AddDays([Fecha], 30)             // sumar 30 días
Date.StartOfMonth([Fecha])            // primer día del mes
Date.EndOfMonth([Fecha])              // último día del mes

Caso 3: Consolidar archivos de múltiples sucursales

Problema real: cada sucursal envía su reporte de ventas mensual como un Excel separado, todos en la misma carpeta. Hay que consolidarlos en una sola tabla y agregar una columna que identifique de qué sucursal proviene cada fila.

let
    // Leer todos los archivos de la carpeta
    Carpeta = Folder.Files("C:\Reportes\Sucursales\2026-06"),

    // Quedarse solo con los Excel
    SoloExcel = Table.SelectRows(Carpeta, each Text.EndsWith([Name], ".xlsx")),

    // Función que lee un archivo y agrega el nombre de sucursal
    fnLeerSucursal = (ruta as binary, nombre as text) as table =>
        let
            Libro     = Excel.Workbook(ruta, null, true){[Name="Ventas"]}[Data],
            ConTipos  = Table.TransformColumnTypes(Libro, {
                            {"Fecha", type date}, {"Producto", type text},
                            {"Monto", Currency.Type}}),
            ConNombre = Table.AddColumn(ConTipos, "Sucursal",
                            each Text.BeforeDelimiter(nombre, ".xlsx"), type text)
        in
            ConNombre,

    // Aplicar la función a cada fila de la tabla de archivos
    TablasPorSucursal = Table.AddColumn(SoloExcel, "Datos",
                            each fnLeerSucursal([Content], [Name])),

    // Apilar todas las tablas en una sola
    Consolidado = Table.Combine(TablasPorSucursal[Datos])
in
    Consolidado

Con este patrón, cuando llega un nuevo archivo a la carpeta, la consulta lo incorpora automáticamente en la próxima actualización — sin ninguna intervención manual.


Caso 4: Cruzar tablas (el BUSCARV del lenguaje M)

Problema real: la tabla de ventas tiene el ID_Vendedor pero no el nombre ni la zona. Esos datos están en una tabla de vendedores separada. Hay que enriquecer las ventas con esa información.

let
    Ventas    = Excel.CurrentWorkbook(){[Name="Ventas"]}[Content],
    Vendedores= Excel.CurrentWorkbook(){[Name="Vendedores"]}[Content],

    // Cruzar por ID_Vendedor (equivalente a BUSCARV / LEFT JOIN)
    Cruzado   = Table.NestedJoin(
                    Ventas,     {"ID_Vendedor"},
                    Vendedores, {"ID"},
                    "DatosVendedor",
                    JoinKind.LeftOuter),

    // Expandir las columnas que necesitamos del cruce
    Expandido = Table.ExpandTableColumn(
                    Cruzado, "DatosVendedor",
                    {"Nombre", "Zona", "Meta_Mensual"})
in
    Expandido

Tipos de join disponibles:

JoinKind Qué hace Equivalente SQL
LeftOuter Todo lo de la izquierda + coincidencias LEFT JOIN
Inner Solo coincidencias en ambas tablas INNER JOIN
FullOuter Todo de ambas tablas FULL OUTER JOIN
RightOuter Todo lo de la derecha + coincidencias RIGHT JOIN
LeftAnti Solo los que NO tienen coincidencia NOT IN

Caso 5: Carga segura de datos con errores conocidos

Problema real: el sistema ERP exporta un campo Codigo_Postal que a veces contiene letras (cuando el cliente es extranjero). Al convertirlo a número, Power Query rompe toda la consulta.

let
    Origen   = Csv.Document(File.Contents("C:\ERP\Pedidos.csv"),
                   [Delimiter=",", Encoding=65001]),
    Tipos    = Table.TransformColumnTypes(
                   Table.PromoteHeaders(Origen), {
                       {"ID_Pedido",  type text},
                       {"Cliente",    type text},
                       {"Cod_Postal", type text},  // lo dejamos text primero
                       {"Total",      Currency.Type}}),

    // Convertir código postal a número SOLO si es posible; si no, dejar null
    ConCP = Table.AddColumn(Tipos, "CP_Numerico",
                each try Int64.From([Cod_Postal]) otherwise null,
                Int64.Type),

    // Identificar los registros que fallaron para revisarlos
    ConFlag = Table.AddColumn(ConCP, "CP_Extranjero",
                  each [CP_Numerico] = null and [Cod_Postal] <> null,
                  type logical)
in
    ConFlag

Patrón try … otherwise:

// Forma básica: si falla, devuelve null
try Number.FromText([Columna]) otherwise null

// Inspeccionar el error para loggear o ramificar
let
    Intento = try Number.FromText([Valor]),
    Resultado = if Intento[HasError]
                then "Error: " & Intento[Error][Message]
                else Text.From(Intento[Value])
in
    Resultado

Usá try … otherwise cuando el fallo es esperado y controlado (datos externos con formatos inconsistentes). No lo uses para ocultar errores de diseño en tu modelo.


Caso 6: Rellenar jerarquías con Fill Down

Problema real: el reporte de inventario exportado desde el ERP tiene la categoría y subcategoría solo en la primera fila de cada grupo — las filas siguientes del grupo tienen esa celda vacía. Power BI no puede agrupar correctamente.

let
    Origen = Excel.Workbook(File.Contents("C:\Inventario\Stock.xlsx"), null, true)
               {[Name="Stock"]}[Data],

    Tipos  = Table.TransformColumnTypes(Origen, {
                 {"Categoria",    type text}, {"Subcategoria", type text},
                 {"Producto",     type text}, {"Stock",        Int64.Type}}),

    // IMPORTANTE: convertir cadenas vacías a null antes de rellenar
    // Si la celda tiene "" en lugar de null, FillDown no funciona
    NullsLimpios = Table.ReplaceValue(Tipos, "", null,
                       Replacer.ReplaceValue, {"Categoria", "Subcategoria"}),

    // Propagar el valor hacia abajo dentro de cada grupo
    Relleno = Table.FillDown(NullsLimpios, {"Categoria", "Subcategoria"})
in
    Relleno

Funciones personalizadas: lógica reutilizable

Cuando aplicás la misma transformación en varias consultas, encapsulala en una función. Así cambiás la lógica en un solo lugar y se actualiza en todos los que la usan.

Caso: varias consultas necesitan normalizar el nombre de una empresa quitando sufijos legales (S.A., Cía. Ltda., S.R.L.) para poder cruzar datos de distintas fuentes.

// Consulta independiente llamada "fnNormalizarEmpresa"
(nombre as text) as text =>
let
    Limpio    = Text.Trim(Text.Clean(nombre)),
    SinSA     = Text.Replace(Limpio,    " S.A.",      ""),
    SinCia    = Text.Replace(SinSA,     " Cía. Ltda.",""),
    SinSRL    = Text.Replace(SinCia,    " S.R.L.",    ""),
    SinLtda   = Text.Replace(SinSRL,    " Ltda.",     ""),
    Final     = Text.Proper(Text.Trim(SinLtda))
in
    Final

Para usarla desde otra consulta:

Table.AddColumn(Origen, "Nombre_Normalizado",
    each fnNormalizarEmpresa([Razon_Social]), type text)

ETL completo: reporte de ventas mensual consolidado

Este caso une todo lo anterior en una sola consulta. El objetivo: consolidar ventas de todas las sucursales, enriquecer con datos de vendedores, calcular indicadores y entregar una tabla lista para el reporte ejecutivo.

let
    // ── 1. Cargar ventas de la carpeta de sucursales ──────────────────────────
    Archivos   = Folder.Files("C:\Ventas\2026-06"),
    SoloExcel  = Table.SelectRows(Archivos, each Text.EndsWith([Name], ".xlsx")),

    fnSucursal = (bin as binary, nombre as text) as table =>
        let
            Hoja     = Excel.Workbook(bin, null, true){[Name="Ventas"]}[Data],
            Tipado   = Table.TransformColumnTypes(Hoja, {
                           {"Fecha", type date}, {"ID_Vendedor", type text},
                           {"ID_Producto", type text}, {"Cantidad", Int64.Type},
                           {"Precio_Unit", Currency.Type}}),
            ConNombre= Table.AddColumn(Tipado, "Sucursal",
                           each Text.BeforeDelimiter(nombre, ".xlsx"), type text)
        in  ConNombre,

    Ventas = Table.Combine(
                 Table.AddColumn(SoloExcel, "D", each fnSucursal([Content],[Name]))[D]),

    // ── 2. Calcular el monto total por línea ──────────────────────────────────
    ConMonto = Table.AddColumn(Ventas, "Monto",
                   each [Cantidad] * [Precio_Unit], Currency.Type),

    // ── 3. Enriquecer con datos del vendedor ──────────────────────────────────
    Vendedores = Excel.CurrentWorkbook(){[Name="Vendedores"]}[Content],
    Cruzado    = Table.NestedJoin(ConMonto, {"ID_Vendedor"},
                     Vendedores, {"ID"}, "V", JoinKind.LeftOuter),
    Expandido  = Table.ExpandTableColumn(Cruzado, "V",
                     {"Nombre_Vendedor", "Zona", "Meta_Mensual"}),

    // ── 4. Agregar columnas de período ────────────────────────────────────────
    ConPeriodo = Table.AddColumn(Expandido, "Mes",
                     each Date.MonthName([Fecha]) & " " & Text.From(Date.Year([Fecha])),
                     type text),

    // ── 5. Flag de cumplimiento de meta (requiere agrupar primero) ────────────
    PorVendedor = Table.Group(ConPeriodo, {"Nombre_Vendedor", "Zona", "Mes", "Meta_Mensual"},{
                     {"Ventas_Total", each List.Sum([Monto]),   Currency.Type},
                     {"Transacciones",each Table.RowCount(_),  Int64.Type}}),

    ConCumplimiento = Table.AddColumn(PorVendedor, "Cumple_Meta",
                          each [Ventas_Total] >= [Meta_Mensual], type logical),

    ConPct = Table.AddColumn(ConCumplimiento, "Pct_Meta",
                 each Number.Round([Ventas_Total] / [Meta_Mensual] * 100, 1),
                 type number),

    // ── 6. Ordenar por zona y monto descendente ───────────────────────────────
    Final = Table.Sort(ConPct, {{"Zona", Order.Ascending},
                                {"Ventas_Total", Order.Descending}})
in
    Final

Operadores y expresiones esenciales

// Concatenar texto
"Zona " & [Zona] & " — " & [Nombre_Vendedor]

// Comparación
[Saldo] > 0          // true / false
[Estado] = "Activo"
[Codigo] <> null

// Lógicos
[Monto] > 1000 and [Estado] = "Aprobado"
[Region] = "Sierra" or [Region] = "Costa"
not [Pagado]

// Coalesce: primer valor no-null
[Telefono_Directo] ?? [Telefono_Movil] ?? "Sin contacto"

// Acceso a posición en lista (base cero)
{"Ene","Feb","Mar"}{1}    // → "Feb"

Errores frecuentes en contexto empresarial

Síntoma Contexto típico Solución
Expression.Error: 'text.trim' no reconocido Copiar código de internet M es case-sensitive: Text.Trim
DataFormat.Error al convertir tipo Campo monetario con símbolo $ o . como separador de miles Limpiar con Text.Replace antes de Number.From
FillDown no rellena nada Reporte de ERP con celdas «vacías» que son "" Table.ReplaceValue(t, "", null, ...) primero
Cruce devuelve filas nulas ID con espacios extra en una de las tablas Text.Trim en la columna clave antes del join
La carpeta trae archivos inesperados Alguien guardó un .xlsm o un PDF en la carpeta Filtrá por extensión con Text.EndsWith([Name], ".xlsx")
Consulta lenta con millones de filas Se trae toda la tabla antes de filtrar Mové el Table.SelectRows lo más arriba posible en el let
Column X of table not found El origen cambió el nombre de un encabezado Usá try Table.Column(t, "X") otherwise null

Descargá la guía completa en PDF

La guía incluye todos los patrones de esta página más:

  • Chuleta de funciones de referencia rápida (Text, Date, Number, List, Table)
  • Caso práctico de ETL paso a paso con capturas
  • Extractores de datos para CSV, Excel, carpetas, SQL y APIs
  • Sección de buenas prácticas y errores comunes

Ingresá tu correo en el formulario que está al inicio del artículo y te llega directo a tu casilla.


Buenas prácticas para código M mantenible

  • Nombrá los pasos con intención. VentasAprobadas es mejor que "Filas filtradas".
  • Comentá las decisiones no obvias. // Brevo exporta "" en vez de null — convertir antes de FillDown.
  • Definí tipos en el paso 2 o 3. Los errores de tipo son mucho más fáciles de depurar temprano.
  • Filtrá y seleccioná columnas lo antes posible. Menos datos en memoria = consultas más rápidas.
  • Una función por lógica reutilizable. Si la misma limpieza aparece en dos consultas, extraela.
  • Usá try … otherwise solo en bordes del sistema. Para datos externos donde el formato no está garantizado.

Artículos relacionados

Comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *