SQL в C# нужен, чтобы программа сохраняла и читала данные в реляционной базе: пользователей, заказы, платежи. Сам C# не умеет общаться с СУБД — запрос на SQL уходит в базу через драйвер (провайдер), а результат возвращается в объекты C#. Ниже — три способа это сделать: «ручной» ADO.NET, микро-ORM Dapper и ORM Entity Framework Core, плюс главное правило безопасности — параметры вместо склейки строк.
Содержание
- Кто за что отвечает: мини-словарь
- Минимальный рабочий пример на ADO.NET
- Параметры против SQL-инъекций
- Транзакция: все или ничего
- Тот же подход на SQL Server: SqlConnection и SqlCommand
- Dapper: свой SQL, меньше ручного кода
- EF Core: LINQ вместо SQL-текста
- Что выбрать: ADO.NET, Dapper или EF Core
- Если не получилось
- Выводы
- Где применяется / связь с практикой
- FAQ
Примеры проверены 24.09.2026 в .NET 10 (SDK 10.0.401) на пакетах Microsoft.Data.Sqlite 10.0.12, Dapper 2.1.89, Microsoft.EntityFrameworkCore.Sqlite 10.0.12. Для воспроизводимости база — SQLite в памяти: ее не нужно устанавливать.
Кто за что отвечает: мини-словарь
Эти слова часто путают, поэтому сначала разведем их.
- SQL — язык запросов к базе (
SELECT,INSERT,UPDATE,DELETE). Его выполняет СУБД, а не .NET. - ADO.NET — базовый API .NET для работы с базами: соединение (
DbConnection), команда (DbCommand), чтение строк (DbDataReader), параметры, транзакции. - Провайдер — пакет, реализующий ADO.NET для конкретной СУБД:
Microsoft.Data.SqlClientдля SQL Server (классыSqlConnection,SqlCommand),Npgsqlдля PostgreSQL,Microsoft.Data.Sqliteдля SQLite. - Dapper — надстройка над ADO.NET: SQL пишете сами, а раскладку строк в объекты делает библиотека.
- EF Core — ORM: вы работаете с классами и LINQ, а SQL генерирует провайдер EF Core.
Все три способа в итоге отправляют в базу SQL. Разница — кто его пишет и кто превращает строки в объекты.
Минимальный рабочий пример на ADO.NET
Консольный проект: dotnet new console, затем dotnet add package Microsoft.Data.Sqlite. Содержимое Program.cs:
using Microsoft.Data.Sqlite;
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
// 1. DDL и первичные данные: ExecuteNonQuery
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
INSERT INTO users (name, email) VALUES
('Анна', 'anna@example.com'),
('Борис', 'boris@example.com');
""";
Console.WriteLine($"Добавлено строк: {cmd.ExecuteNonQuery()}");
}
// 2. Одно значение: ExecuteScalar
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM users";
long count = (long)cmd.ExecuteScalar()!;
Console.WriteLine($"Всего пользователей: {count}");
}
// 3. Набор строк с параметром: ExecuteReader
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT id, name FROM users WHERE email = $email";
cmd.Parameters.AddWithValue("$email", "boris@example.com");
using var reader = cmd.ExecuteReader();
while (reader.Read())
Console.WriteLine($"Найден: {reader.GetInt64(0)} {reader.GetString(1)}");
}
dotnet run печатает:
Добавлено строк: 2
Всего пользователей: 2
Найден: 2 Борис
Разбор по шагам. Соединение открывается один раз и закрывается через using — даже если по дороге вылетит исключение. Команда несет текст SQL. Метод выполнения выбирается по тому, что вы ждете от запроса:
| Метод | Что возвращает | Когда брать |
|---|---|---|
ExecuteNonQuery() |
число затронутых строк | INSERT, UPDATE, DELETE, DDL |
ExecuteScalar() |
первое поле первой строки (object) |
COUNT(*), MAX(...), id новой записи |
ExecuteReader() |
поток строк, читается по одной через Read() |
SELECT с несколькими строками |
Две оговорки. Для DDL число строк не имеет смысла: провайдеры возвращают разные значения, полагаться на него стоит только для DML. ExecuteScalar вернет null, если строк нет, и DBNull.Value, если значение в базе NULL — приведение типа без проверки в этих случаях упадет. У каждого метода есть асинхронная версия (ExecuteReaderAsync и т. д.) — в веб-приложениях используют их.
Параметры против SQL-инъекций
Самая частая ошибка новичка — собрать запрос из строки, которую ввел пользователь. Продолжим тот же Program.cs:
// НЕВЕРНО: пользовательский ввод склеен в текст SQL
string input = "x' OR '1'='1";
using (var bad = conn.CreateCommand())
{
bad.CommandText = $"SELECT name FROM users WHERE email = '{input}'";
using var r = bad.ExecuteReader();
while (r.Read()) Console.WriteLine($"Утечка: {r.GetString(0)}");
}
// ВЕРНО: ввод передается параметром, SQL-текст не меняется
using (var good = conn.CreateCommand())
{
good.CommandText = "SELECT name FROM users WHERE email = $email";
good.Parameters.AddWithValue("$email", input);
using var r = good.ExecuteReader();
Console.WriteLine($"Строк по параметру: {(r.Read() ? "есть" : "0")}");
}
Результат:
Утечка: Анна
Утечка: Борис
Строк по параметру: 0
В первом случае кавычка из ввода закрыла строковый литерал, и условие превратилось в email = 'x' OR '1'='1' — истинно для всех строк. Во втором значение ушло в базу отдельно от текста запроса, поэтому СУБД сравнила email с буквальной строкой x' OR '1'='1 и ничего не нашла.
Параметры защищают только значения. Имя таблицы, столбца или направление сортировки параметром не передать — такие части берите из белого списка в коде, а не из ввода. Экранирование кавычек вручную — не замена параметрам. И параметризация — одна из мер, а не вся защита: права учетной записи приложения в базе стоит ограничить минимально нужными.
Синтаксис параметров зависит от провайдера: в SQLite работают $name, @name и :name, в SQL Server — @name, в Npgsql — @name или позиционные $1.
Транзакция: все или ничего
Если несколько изменений должны пройти вместе, их выполняют в одной транзакции. Вторая вставка ниже нарушает UNIQUE по email:
// Две вставки как одно целое: вторая нарушает UNIQUE
using (var tx = conn.BeginTransaction())
{
try
{
foreach (var (name, email) in new[] {
("Вера", "vera@example.com"),
("Анна-2", "anna@example.com") }) // дубль email
{
using var ins = conn.CreateCommand();
ins.Transaction = tx;
ins.CommandText = "INSERT INTO users (name, email) VALUES ($n, $e)";
ins.Parameters.AddWithValue("$n", name);
ins.Parameters.AddWithValue("$e", email);
ins.ExecuteNonQuery();
}
tx.Commit();
}
catch (SqliteException ex)
{
tx.Rollback();
Console.WriteLine($"Откат: {ex.Message}");
}
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM users";
Console.WriteLine($"После отката: {cmd.ExecuteScalar()}");
}
Откат: SQLite Error 19: 'UNIQUE constraint failed: users.email'.
После отката: 2
«Вера» тоже не сохранилась: откат отменил обе вставки. Если вылетит исключение другого типа, catch его не поймает, но using вызовет Dispose, и незакоммиченная транзакция будет откачена (это проверено отдельным прогоном). Строку ins.Transaction = tx лучше писать всегда: Microsoft.Data.Sqlite 10 подхватывает активную транзакцию соединения и без нее, а в SqlClient команда без привязки к открытой транзакции завершится исключением.
Тот же подход на SQL Server: SqlConnection и SqlCommand
Для SQL Server код тот же по структуре, меняется провайдер. Актуальный пакет — Microsoft.Data.SqlClient, старый System.Data.SqlClient для новых проектов брать не стоит.
using System.Data;
using Microsoft.Data.SqlClient;
// Строку подключения держим в конфигурации/секретах, не в коде
string cs = Environment.GetEnvironmentVariable("SHOP_DB")
?? throw new InvalidOperationException("SHOP_DB не задана");
string email = args.Length > 0 ? args[0] : "boris@example.com";
await using var conn = new SqlConnection(cs);
await conn.OpenAsync();
await using var cmd = new SqlCommand(
"SELECT id, name FROM dbo.users WHERE email = @email", conn);
cmd.Parameters.Add("@email", SqlDbType.NVarChar, 256).Value = email;
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
Console.WriteLine($"{reader.GetInt32(0)}: {reader.GetString(1)}");
Здесь параметр задан с явным типом и длиной (NVarChar, 256), а не через AddWithValue. Для SQL Server это практичнее: AddWithValue выводит тип из значения C#, и при столбце VARCHAR сравнение с NVARCHAR-параметром может помешать использовать индекс. Это вопрос производительности, а не безопасности — параметр защищает от инъекции в обоих вариантах. Пул соединений провайдер ведет сам, поэтому соединение открывают на короткую операцию и сразу закрывают.
Dapper: свой SQL, меньше ручного кода
Dapper добавляет к соединению методы Query<T> и Execute. Параметры передаются анонимным объектом и тоже уходят в базу отдельно от текста. Пакеты: Dapper и Microsoft.Data.Sqlite.
using Dapper;
using Microsoft.Data.Sqlite;
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
conn.Execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE)");
int added = conn.Execute(
"INSERT INTO users (name, email) VALUES (@Name, @Email)",
new[] {
new { Name = "Анна", Email = "anna@example.com" },
new { Name = "Борис", Email = "boris@example.com" } });
Console.WriteLine($"Добавлено: {added}");
var found = conn.Query<User>(
"SELECT id, name, email FROM users WHERE name LIKE @prefix",
new { prefix = "Б%" });
foreach (var u in found)
Console.WriteLine($"{u.Id}: {u.Name} <{u.Email}>");
class User
{
public long Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}
Добавлено: 2
2: Борис <boris@example.com>
Столбцы сопоставились со свойствами по имени без учета регистра. Если передать массив объектов в Execute, Dapper выполнит команду для каждого элемента — это удобно, но это не пакетная вставка одним запросом.
EF Core: LINQ вместо SQL-текста
В EF Core вы описываете классы и DbContext, а запросы пишете на LINQ. Пакет — Microsoft.EntityFrameworkCore.Sqlite.
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open(); // in-memory база живет, пока открыто соединение
var options = new DbContextOptionsBuilder<AppDb>().UseSqlite(conn).Options;
using var db = new AppDb(options);
db.Database.EnsureCreated(); // для демо; в проекте - миграции
db.Users.AddRange(
new User { Name = "Анна", Email = "anna@example.com" },
new User { Name = "Борис", Email = "boris@example.com" });
Console.WriteLine($"Сохранено: {db.SaveChanges()}");
string email = "boris@example.com";
var query = db.Users.Where(u => u.Email == email).Select(u => u.Name);
Console.WriteLine(query.ToQueryString());
Console.WriteLine($"Результат: {query.Single()}");
// Свой SQL, но параметризованный: FromSql превращает {email} в параметр
var viaSql = db.Users.FromSql($"SELECT * FROM Users WHERE Email = {email}").ToList();
Console.WriteLine($"FromSql нашел: {viaSql.Count}");
class User
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}
class AppDb(DbContextOptions<AppDb> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
}
Сохранено: 2
.param set @email 'boris@example.com'
SELECT "u"."Name"
FROM "Users" AS "u"
WHERE "u"."Email" = @email
Результат: Борис
FromSql нашел: 1
ToQueryString() показывает, какой SQL EF Core отправит в базу: переменная email стала параметром @email. FromSql с интерполированной строкой тоже создает параметры. А вот FromSqlRaw и ExecuteSqlRaw со склеенной строкой возвращают ту же уязвимость, что и пример «НЕВЕРНО» выше.
Что выбрать: ADO.NET, Dapper или EF Core
| Задача | Что взять | Почему | Когда иначе |
|---|---|---|---|
| Учебный проект, понять основы | ADO.NET | видно каждое действие: соединение, команда, чтение | — |
| Отчеты, сложный SQL, контроль над запросом | Dapper | SQL пишете сами, маппинг без рутины | много однотипного CRUD — EF Core |
| Бизнес-приложение с моделью предметной области | EF Core | миграции, отслеживание изменений, LINQ | горячие запросы — проверить сгенерированный SQL |
| Массовая загрузка данных | средства провайдера (SqlBulkCopy для SQL Server) |
построчные INSERT медленные |
небольшие объемы — обычная транзакция |
Подходы совмещаются: в одном проекте EF Core для типового кода и Dapper или сырой ADO.NET для тяжелых отчетов. Что быстрее в конкретном случае, зависит от запроса, СУБД и объема данных — это измеряют, а не берут из таблицы.
Если не получилось
SQLite Error 1: 'no such table: users'с in-memory базой — соединение было закрыто или создано новое: база:memory:живет, пока открыто ее соединение.InvalidCastExceptionпри(long)cmd.ExecuteScalar()— значение в базеNULL(пришелDBNull.Value, напримерMAX(id)по пустой выборке) или тип другой (в SQL ServerCOUNT(*)— этоint).NullReferenceExceptionтам же — запрос не вернул ни одной строки,ExecuteScalar()далnull. Надежнее проверять результат перед приведением:cmd.ExecuteScalar() is long n ? n : 0.- Строка подключения SQL Server не работает после обновления пакета — в
Microsoft.Data.SqlClientшифрование соединения включено по умолчанию, и сервер с самоподписанным сертификатом не пройдет проверку. Правильное решение — доверенный сертификат, а не отключение проверки в рабочей среде. - Запрос работает в SSMS, но не из кода — проверьте, к какой базе и под какой учетной записью подключается приложение: права у нее обычно меньше.
Выводы
- SQL в C# нужен для работы с реляционными базами: C# формирует запрос, провайдер ADO.NET передает его в СУБД и возвращает результат.
- Базовая схема ADO.NET: соединение -> команда ->
ExecuteNonQuery,ExecuteScalarилиExecuteReaderв зависимости от ожидаемого результата. - Пользовательский ввод — только параметрами; склейка строк дает SQL-инъекцию, что видно на реальном прогоне выше.
- Связанные изменения — в транзакции с откатом при ошибке.
- Dapper — когда SQL хочется писать самому, EF Core — когда нужна модель данных и миграции; оба работают поверх ADO.NET.
Где применяется / связь с практикой
Освойте тему на практике
Работа с базой — часть почти любого C#-проекта: веб-API на ASP.NET Core, настольные программы, фоновые сервисы. Чтобы уверенно писать такой код, нужна база самого языка: классы, коллекции, исключения, async/await, LINQ. Эти темы последовательно разбирают на курсе C#-разработчик. Базовый уровень. Посмотреть формат занятий без записи на курс можно на открытых уроках Otus.
FAQ
Нужно ли знать SQL, если есть EF Core?
Да. EF Core генерирует SQL, но чтобы понять медленный запрос, прочитать план или написать отчет, нужно читать и писать SQL самому.
Можно ли держать одно соединение открытым на все приложение?
Обычно нет: соединение не потокобезопасно. Открывайте его на операцию и закрывайте — провайдер переиспользует физические соединения через пул.
Чем Microsoft.Data.SqlClient отличается от System.Data.SqlClient?
Это развитие того же провайдера для SQL Server в отдельном пакете: новые версии, поддержка современных функций. Старый пакет оставляют только в legacy-коде.



