En C#, un salto de línea no es un solo carácter. Puede ser cualquiera de siete secuencias, y ReplaceLineEndings() es el único método que las reconoce todas: text.ReplaceLineEndings("\n") normaliza en una sola llamada una cadena con saltos de línea mezclados.
String.Replace() y Regex.Replace() también sirven, pero solo para las secuencias que indicamos nosotros, y solo en el orden correcto. Cuál de los tres elegir depende de si sabemos qué contiene el texto.
¿Qué cuenta como salto de línea en C#?
En C#, un salto de línea no es un solo carácter, sino cualquiera de siete secuencias, y cuáles tenemos que manejar depende por completo de la procedencia del texto.
Tres de ellas aparecen en el código del día a día. El avance de línea (line feed), que se escribe \n, es U+000A y es lo que escriben Unix, Linux y las versiones modernas de macOS. El retorno de carro (carriage return), \r, es U+000D. El par formado por el retorno de carro y el avance de línea, \r\n, es lo que escribe Windows, y cuenta como un solo salto, no como dos.
Las otras cuatro son menos habituales y aparecen en texto que ha llegado de otra parte: el avance de página (form feed) en U+000C, la línea siguiente (next line) en U+0085, el separador de línea en U+2028 y el separador de párrafo en U+2029.
Environment.NewLine es la secuencia que escribe la plataforma actual. Devuelve "\r\n" en Windows y "\n" en Linux y macOS, así que es el valor correcto para escribir y el incorrecto para buscar.
En esa diferencia está todo el problema. Escribir requiere una sola secuencia. Al leer, hay que estar preparado para las siete.
Los finales de línea mezclados son lo normal, no un caso límite: un archivo escrito en Windows, una cadena devuelta por un servicio de Linux y un texto pegado desde un formulario web pueden acabar en la misma variable. La lectura de archivos es la fuente habitual de los finales de línea mezclados, y cualquier código que tenga que contar las líneas de un archivo tiene que decidir qué es un salto de línea antes de poder empezar a contar.
¿Cómo reemplazar los saltos de línea con String.Replace()?
String.Replace() cambia cada aparición de una subcadena por otra y devuelve una cadena nueva. Para reemplazar los saltos de línea con \n hay que llamarlo dos veces, y el orden en que se ejecutan las dos llamadas decide si el resultado es correcto.
Primero reemplazamos \r\n y después \r. Si invertimos el orden, rompemos el par de Windows: al reemplazar \r por separado, el \n que lo sigue queda intacto, así que cada salto de línea de Windows se convierte en dos avances de línea en lugar de uno.
El método es una simple búsqueda de subcadenas. No sabe nada de saltos de línea, así que solo reconoce las secuencias que indicamos y nada más. Las cuatro secuencias menos habituales pasan sin cambios, igual que cualquier secuencia que hayamos olvidado.
Replace() devuelve la instancia original de la cadena cuando no encuentra ninguna coincidencia, así que una llamada sin coincidencias no asigna nada. Conviene saberlo antes de leer cualquier benchmark que mida estas llamadas encadenadas sobre un texto con un solo tipo de salto de línea.
Es la herramienta indicada cuando sabemos exactamente qué contiene el texto.
Estas son las llamadas encadenadas en el orden correcto:
const string text = "Line one.\r\nLine two.\nLine three.\rLine four.";
var newText = text.Replace("\r\n", "\n").Replace("\r", "\n");
El texto de prueba contiene las tres secuencias habituales, así que las dos llamadas tienen algo que hacer. Obtenemos este resultado:
Line one.\nLine two.\nLine three.\nLine four.
Intercambiar las dos llamadas convertiría el primer \r\n en \n\n y dejaría la cadena con una línea más de las que tenía al principio.
¿Cómo reemplazar los saltos de línea con ReplaceLineEndings()?
ReplaceLineEndings() llegó con .NET 6 y sigue vigente en .NET 10. Reemplaza cada secuencia de salto de línea por el texto que le pasamos. Una sola llamada y ningún orden en el que equivocarse.
Reconoce siete secuencias del estándar Unicode: el retorno de carro, el avance de línea, los dos juntos como par, la línea siguiente, el avance de página, el separador de línea y el separador de párrafo. El par cuenta como un solo salto, y por eso aquí un salto de línea de Windows nunca se duplica, como sí puede ocurrir con String.Replace().
La sobrecarga sin parámetros reemplaza las siete por Environment.NewLine, con lo que normaliza el texto mixto a lo que escriba la plataforma. Es la llamada adecuada cuando el texto está a punto de escribirse.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
Si pasamos "\n", el runtime sigue una ruta de código dedicada, así que normalizar a los finales de línea de Unix es el caso rápido, no el general.
Hay una advertencia importante: no lo uses nunca en un analizador de protocolos. HTTP exige CRLF, y un método que trata U+2028 como salto de línea es demasiado permisivo para esa tarea.
Por ejemplo, podemos normalizarlo todo al avance de línea de Unix:
const string text = "Line one.\r\nLine two.\nLine three.\rLine four.";
var newText = text.ReplaceLineEndings("\n");
Una sola llamada se ocupa del par de Windows, del retorno de carro suelto y del avance de línea que ya era correcto. Obtenemos el resultado:
Line one.\nLine two.\nLine three.\nLine four.
La advertencia sobre los analizadores de protocolos no es nuestra. La referencia de String.ReplaceLineEndings de Microsoft dice: “No se recomienda que los analizadores de protocolos utilicen esta API”.
La tabla muestra cada secuencia que reconoce el método y qué hacen con ella los otros dos enfoques de este artículo:
| Secuencia | Punto de código | Nombre | Lo reconoce ReplaceLineEndings() | Lo reconocen las llamadas encadenadas a Replace() | Lo reconoce la expresión regular |
|---|---|---|---|---|---|
\n | U+000A | Avance de línea (LF) | Sí | No | Sí |
\r | U+000D | Retorno de carro (CR) | Sí | Sí | Sí |
\r\n | U+000D U+000A | CRLF, como un solo salto | Sí | Sí | Sí |
\u0085 | U+0085 | Línea siguiente (NEL) | Sí | No | No |
\f | U+000C | Avance de página (FF) | Sí | No | No |
\u2028 | U+2028 | Separador de línea (LS) | Sí | No | No |
\u2029 | U+2029 | Separador de párrafo (PS) | Sí | No | No |
¿Cómo reemplazar los saltos de línea con una expresión regular?
Las expresiones regulares permiten buscar patrones con precisión y reemplazar patrones de caracteres en las cadenas. Por eso el método Regex.Replace() es una opción válida para manejar saltos de línea, aunque rara vez la mejor.
La forma actual de hacerlo es una expresión regular creada con el generador de código fuente, declarada como método parcial de una clase parcial cuyo archivo empieza con using System.Text.RegularExpressions;:
[GeneratedRegex(@"\r\n|\r|\n")] private static partial Regex LineBreakRegex();
El patrón incluye las tres secuencias habituales y pone \r\n en primer lugar para que el par de Windows se consuma como una sola coincidencia y no como dos. Después, desde dentro de esa clase, lo llamamos con la cadena de entrada y el texto de reemplazo:
const string text = "Line one.\r\nLine two.\nLine three.\rLine four."; var newText = LineBreakRegex().Replace(text, "\n");
Aquí Regex.Replace() encuentra las tres secuencias de salto de línea habituales y reescribe cada una como \n. Las cuatro secuencias menos habituales de la tabla anterior no están en el patrón, así que quedan intactas. Y, de nuevo, obtenemos el resultado:
Line one.\nLine two.\nLine three.\nLine four.
Una expresión regular es la herramienta adecuada cuando el salto de línea forma parte de un patrón más amplio que buscamos de todos modos. Por sí sola, no hace nada que no hagan ya los dos métodos anteriores, y suele ser la más costosa de ejecutar.
¿Qué método conviene usar para reemplazar saltos de línea?
Antes que la velocidad, lo decide la corrección del resultado, y la corrección apunta a ReplaceLineEndings(). Reconoce las siete secuencias, no tiene ninguna regla de orden en la que equivocarse y su nombre dice lo que hace.
La velocidad ya no lo separa de String.Replace(). En un párrafo con finales de línea mezclados, los dos quedan a unos diez nanosegundos el uno del otro e intercambian posiciones de una ejecución a otra, así que ninguno es más rápido que el otro de forma fiable en ninguna de las dos entradas que medimos.
La asignación de memoria sí los separa en el párrafo mixto. Las llamadas encadenadas a Replace() hacen dos pasadas y asignan una cadena en cada una, el doble de memoria que la única pasada de ReplaceLineEndings().
Regex.Replace() es, con diferencia, el más lento de los tres en ambas entradas, y no aporta nada cuando lo único que buscamos es el salto de línea.
String.Replace() es la opción cuando sabemos exactamente qué secuencias contiene el texto y no queremos tocar nada más. El texto que hemos generado nosotros es ese caso; el que viene de un usuario o de una API, no.
Para medirlo, usamos la biblioteca BenchmarkDotNet y ejecutamos cada método sobre dos entradas: una cadena corta con un solo salto y un párrafo que mezcla \r\n, \n y \r:
[Params("Short", "Mixed")]
public string Input { get; set; } = "Short";
[GlobalSetup]
public void GlobalSetup() => _text = Input == "Short" ? ShortText : MixedText;
[Benchmark]
public string StringReplace() =>
ReplaceLineBreak.ReplaceLineBreaksUsingTheStringReplaceMethod(_text);
[Benchmark]
public string StringReplaceLineEndings() =>
ReplaceLineBreak.ReplaceLineBreaksUsingTheStringReplaceLineEndingsMethod(_text);
[Benchmark]
public string RegexReplace() =>
ReplaceLineBreak.ReplaceLineBreaksUsingTheRegularExpressionReplaceMethod(_text);
El atributo [Params] ejecuta todo el conjunto dos veces, una por entrada, así que los dos casos aparecen uno junto al otro en una sola tabla. La clase completa, con [MemoryDiagnoser], las dos cadenas de entrada y los métodos de ReplaceLineBreak que envuelven las llamadas mostradas antes, está en el código fuente enlazado al principio. Ahora podemos ejecutar el benchmark y evaluar los resultados:
| Method | Input | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | |------------------------- |------ |----------:|---------:|---------:|-------:|-------:|----------:| | StringReplace | Mixed | 213.71 ns | 4.150 ns | 4.613 ns | 0.2275 | 0.0002 | 1904 B | | StringReplaceLineEndings | Mixed | 202.75 ns | 2.508 ns | 2.094 ns | 0.1137 | - | 952 B | | RegexReplace | Mixed | 486.05 ns | 6.806 ns | 6.034 ns | 0.1135 | - | 952 B | | StringReplace | Short | 33.16 ns | 0.717 ns | 1.158 ns | 0.0114 | - | 96 B | | StringReplaceLineEndings | Short | 32.69 ns | 0.411 ns | 0.384 ns | 0.0114 | - | 96 B | | RegexReplace | Short | 98.23 ns | 0.892 ns | 0.745 ns | 0.0114 | - | 96 B |
En la cadena corta, los dos métodos de String empatan en unos 33 ns, y en el párrafo mixto vuelven a empatar, con entre 200 y 215 ns aproximadamente. Ese orden no es estable: en tres ejecuciones de este benchmark en la misma máquina, StringReplace quedó por delante de StringReplaceLineEndings en la entrada mixta una vez y por detrás dos veces, con márgenes menores que la dispersión entre ejecuciones. Ninguno de los dos métodos es apreciablemente más rápido que el otro con estas dos entradas, y una sola ejecución puede poner a cualquiera de ellos por delante.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
La memoria es la diferencia real. En el párrafo mixto, las llamadas encadenadas a Replace() asignan 1904 bytes frente a los 952 de ReplaceLineEndings(), exactamente el doble, porque dos llamadas encadenadas construyen dos cadenas donde una sola llamada construye una. En la cadena corta, los tres asignan los mismos 96 bytes, porque la primera de las llamadas encadenadas no encuentra ningún \r\n y devuelve la instancia original sin asignar nada.
Regex.Replace() es el caso atípico en las dos entradas: cuesta aproximadamente entre 2.3 y 2.4 veces lo que cualquiera de los dos métodos de String en el párrafo mixto, y unas 3 veces en la cadena corta.
| Método | Reconoce | Trampa del orden | Úsalo cuando |
|---|---|---|---|
ReplaceLineEndings() | Las siete secuencias | Ninguna, una sola llamada | No controlamos el texto, o viene de un archivo, una API o un usuario |
String.Replace() | Solo las secuencias que indicamos | Sí: reemplazar \r\n antes que \r | Sabemos exactamente qué secuencias contiene el texto y no queremos tocar otras |
Regex.Replace() | Solo lo que incluye el patrón | Sí: el mismo orden en la alternancia | El salto de línea forma parte de un patrón más amplio que buscamos de todos modos |
¿Cómo eliminar los saltos de línea en lugar de reemplazarlos?
Pasa una cadena vacía. text.ReplaceLineEndings("") elimina todas las secuencias de salto de línea y une las líneas sin nada entre ellas. Es un comportamiento documentado, no un efecto secundario de un reemplazo vacío.
Eso sí, el resultado suele leerse mal. Dos frases que terminaban en líneas distintas quedan pegadas en una, así que reemplazar por un solo espacio es la opción por defecto más útil: text.ReplaceLineEndings(" ") da un texto que todavía se puede leer.
Eliminar los saltos de línea no es lo mismo que eliminar los espacios en blanco. ReplaceLineEndings("") deja cada tabulación, cada espacio y toda la sangría del principio de cada línea exactamente donde estaba.
String.Replace() puede hacer lo mismo en dos llamadas: elimina \r y después \n. La trampa del orden desaparece cuando el reemplazo está vacío, ya que las dos mitades del par de Windows se eliminan de todos modos, pero las cuatro secuencias menos habituales siguen colándose.
Ninguno de los dos enfoques recorta el resultado. Una cadena que terminaba en un salto de línea pierde el salto y conserva los espacios que hubiera antes de él.
En código, es una sola llamada:
const string text = "Line one.\r\nLine two.\nLine three.\rLine four."; var newText = text.ReplaceLineEndings(string.Empty);
Y obtenemos todas las líneas pegadas:
Line one.Line two.Line three.Line four.
Si lo que realmente queremos son las líneas en sí, y no una cadena larga, es más barato dividir el texto en líneas en lugar de reescribir los saltos. Y si el objetivo es eliminar todos los caracteres de espacio en blanco, y no solo los saltos de línea, se trata de otra tarea, con otro método.
Conclusión
En este artículo, hemos reemplazado los saltos de línea de una cadena en C# de tres formas. La opción por defecto es ReplaceLineEndings(): reconoce las siete secuencias de salto de línea que define el estándar Unicode, trata un \r\n de Windows como un solo salto y no como dos, y necesita una sola llamada, sin ninguna regla de orden que recordar.
String.Replace() es la opción adecuada cuando sabemos exactamente qué secuencias contiene el texto y no queremos tocar nada más. Solo reconoce lo que le indicamos, así que tenemos que indicar \r\n antes que \r, y asigna una cadena por cada llamada que encuentra una coincidencia.
Regex.Replace() solo compensa su costo cuando el salto de línea forma parte de un patrón más amplio que ya estamos buscando. Por sí solo, es con holgura el más lento de los tres con textos cortos.
Para eliminar los saltos de línea no hace falta otro método: ReplaceLineEndings("") los borra todos, y un solo espacio suele ser el reemplazo más legible.
Probado con el SDK de .NET 10.0.302.
