Sheetly brings the Entity Framework Core developer experience to Google Sheets and Excel. If you know EF Core, you already know Sheetly.
public class Product
{
public int Id { get; set; }
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
}
public class AppContext : SheetsContext
{
public SheetsSet<Product> Products { get; set; }
public SheetsSet<Category> Categories { get; set; }
protected override void OnConfiguring(SheetsOptions options)
{
options.UseGoogleSheets("your-spreadsheet-id", "credentials.json");
// or: options.UseExcel("data.xlsx");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasSheetName("Products");
entity.Property(p => p.Name).HasMaxLength(100);
entity.Property(p => p.Price).IsRequired();
});
}
}
// Use it like EF Core
await using var context = new AppContext();
await context.InitializeAsync();
context.Products.Add(new Product { Name = "Laptop", Price = 1200 });
await context.SaveChangesAsync();
var products = await context.Products
.Include(p => p.Category)
.ToListAsync();SheetsContextandSheetsSet<T>β familiar patternsAdd(),Update(),Remove(),SaveChangesAsync()- Automatic change tracking β modify entities and call
SaveChangesAsync()without explicitUpdate() Include()with string and expression-based overloads (Include(p => p.Category))AsNoTracking()for read-only queriesFindAsync(),FirstOrDefaultAsync(),Where(),CountAsync(),AnyAsync()- Query operators:
OrderBy(),OrderByDescending(),Skip(),Take(),SelectAsync()(pagination & projection) - Identity map β the same row loaded twice returns the same tracked instance
CancellationTokensupport onSaveChangesAsync()IAsyncDisposableβ useawait usingfor automatic cleanup
- C# migration files with fully-generated Up/Down methods (reversible by default)
- Automatic model change detection β including PK/FK/unique/auto-increment changes
- Column & table rename operations (data preserved, not drop+add)
- Migration history tracking and rollback of the last applied migration
- Database-first scaffold from an existing spreadsheet
- Schema synchronization checks at startup
dotnet sheetly migrations add InitialCreate
dotnet sheetly database update
dotnet sheetly migrations rollback- Primary Keys (auto-detected, auto-increment) and composite keys (
HasKey(e => new { e.A, e.B })) - Foreign Keys (auto-detected) with delete behavior (
OnDelete(Cascade/SetNull/SetDefault/Restrict)) - Unique constraints (
IsUnique()) β checked against existing rows and the pending batch - Required/Nullable (
IsRequired()) - Max/Min Length (
HasMaxLength(),HasMinLength()) - Range validation (
HasRange()) - Default values (
HasDefaultValue()) - Column mapping (
HasColumnName()) - Optimistic concurrency (
IsConcurrencyToken(),IsRowVersion()) - Local validation before API calls (strict β throws on type/constraint violations, like EF Core)
- Hidden __SheetlySchema__ sheet stores all metadata
- Hidden __SheetlyMigrationsHistory__ tracks applied migrations
- Batch operations β adding N entities uses a single API call
- In-memory sheet metadata cache β
SheetExistsAsynccosts 0 API calls after init - Optimized
FindAsyncβ scans only the PK column instead of full data - Automatic retry with exponential backoff on rate limits
- Multiple credentials rotation β distribute API quota across service accounts
dotnet tool install -g dotnet-sheetly
dotnet sheetly migrations add MyMigration
dotnet sheetly migrations list
dotnet sheetly migrations remove
dotnet sheetly migrations rollback
dotnet sheetly database update
dotnet sheetly database drop
dotnet sheetly scaffold| Package | Description |
|---|---|
Sheetly.Core |
Core abstractions, migrations, validation |
Sheetly.Google |
Google Sheets API provider |
Sheetly.Excel |
Local Excel (.xlsx) file provider |
dotnet-sheetly |
CLI tool for migrations |
Sheetly.DependencyInjection |
ASP.NET Core DI integration |
dotnet add package Sheetly.Core
# Pick your provider:
dotnet add package Sheetly.Google # Google Sheets (online)
dotnet add package Sheetly.Excel # Excel .xlsx (local)
# For ASP.NET Core apps
dotnet add package Sheetly.DependencyInjection
# CLI tool
dotnet tool install -g dotnet-sheetly- Go to Google Cloud Console
- Create a new project
- Enable Google Sheets API
- Create credentials (Service Account)
- Download
credentials.json - Share your spreadsheet with the service account email
π Keep
credentials.jsonout of source control β add it to.gitignoreand commit a placeholder likecredentials.example.jsoninstead.
protected override void OnConfiguring(SheetsOptions options)
{
options.UseGoogleSheets("your-spreadsheet-id", "credentials.json");
}dotnet add package Sheetly.Excelprotected override void OnConfiguring(SheetsOptions options)
{
options.UseExcel("C:/data/myapp.xlsx");
}No API keys, no internet β all data stays on disk.
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public List<Product> Products { get; set; } = [];
}
public class Product
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public decimal Price { get; set; }
public string? Description { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
}Primary keys (Id) and foreign keys (CategoryId β Category) are auto-detected by convention.
using Sheetly.Core;
using Sheetly.Google;
public class MyAppContext : SheetsContext
{
public SheetsSet<Product> Products { get; set; }
public SheetsSet<Category> Categories { get; set; }
protected override void OnConfiguring(SheetsOptions options)
{
options.UseGoogleSheets("your-spreadsheet-id", "credentials.json");
// or: options.UseExcel("mydata.xlsx");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasSheetName("Products");
entity.Property(p => p.Title).HasMaxLength(200).IsRequired();
entity.Property(p => p.Price).IsRequired();
});
modelBuilder.Entity<Category>(entity =>
{
entity.HasSheetName("Categories");
entity.Property(c => c.Name).HasMaxLength(100);
});
}
}dotnet sheetly migrations add InitialCreate
dotnet sheetly database updateawait using var context = new MyAppContext();
await context.InitializeAsync();
// Create
var category = new Category { Name = "Electronics" };
context.Categories.Add(category);
await context.SaveChangesAsync();
var product = new Product
{
Title = "Laptop",
Price = 1200,
CategoryId = category.Id
};
context.Products.Add(product);
await context.SaveChangesAsync();
// Query with Include
var products = await context.Products.Include(p => p.Category).ToListAsync();
foreach (var p in products)
Console.WriteLine($"{p.Title} - ${p.Price} - {p.Category.Name}");
// Update (auto change tracking β no explicit Update() needed)
product.Price = 1100;
await context.SaveChangesAsync();
// Delete
context.Products.Remove(product);
await context.SaveChangesAsync();protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasSheetName("Products");
entity.Property(p => p.Title)
.HasMaxLength(200)
.HasMinLength(2)
.HasColumnName("Product_Title");
entity.Property(p => p.Price)
.IsRequired()
.HasRange(0, 999999);
entity.Property(p => p.Stock)
.HasDefaultValue(0);
});
}// Parameterless constructor (classic)
builder.Services.AddSheetsContext<MyAppContext>(options =>
options.UseGoogleSheets("spreadsheet-id", "credentials.json"));
// Options constructor (EF Core-style)
public class MyAppContext : SheetsContext
{
public MyAppContext(SheetsContextOptions<MyAppContext> options) : base(options) { }
public SheetsSet<Product> Products { get; set; }
}
builder.Services.AddSheetsContext<MyAppContext>(options =>
options.UseGoogleSheets("spreadsheet-id", "credentials.json"));var products = await context.Products.AsNoTracking().ToListAsync();var product = await context.Products.FindAsync(1);
var first = await context.Products.FirstOrDefaultAsync(p => p.Price > 100);
var filtered = await context.Products.Where(p => p.CategoryId == 1);
var count = await context.Products.CountAsync();
var any = await context.Products.AnyAsync(p => p.Price > 0);// Sort, page and project β composable, materialized with ToListAsync()
var page = await context.Products
.OrderByDescending(p => p.Price)
.Skip(20)
.Take(10)
.ToListAsync();
var names = await context.Products
.OrderBy(p => p.Title)
.SelectAsync(p => p.Title);public class OrderLine
{
public int OrderId { get; set; }
public int LineNo { get; set; }
public string Product { get; set; } = string.Empty;
public int Quantity { get; set; }
}
modelBuilder.Entity<OrderLine>(entity =>
{
entity.HasSheetName("OrderLines");
entity.HasKey(l => new { l.OrderId, l.LineNo });
});
// Uniqueness is enforced on the key combination, not each column.
// Look up by the full key (in declaration order):
var line = await context.OrderLines.FindAsync(orderId, lineNo);Composite-keyed entities are never auto-incremented β supply both key values yourself.
modelBuilder.Entity<Employee>(entity =>
{
// When a Department is deleted, its Employees are deleted too
entity.Property(e => e.DepartmentId).OnDelete(ForeignKeyAction.Cascade);
// Other options: SetNull, SetDefault, Restrict (default)
});SaveChangesAsync() enforces these rules locally before writing: Restrict blocks the delete if dependents exist, Cascade removes them, SetNull/SetDefault rewrites the FK column.
public class Document
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public int Version { get; set; } // row version
}
modelBuilder.Entity<Document>(entity =>
{
entity.Property(d => d.Version).IsRowVersion();
// or, for an arbitrary token column: .IsConcurrencyToken()
});On update, Sheetly re-reads the row and compares the concurrency token. If another process changed it in the meantime, a DbUpdateConcurrencyException is thrown (reload and retry). IsRowVersion() columns are auto-incremented on every save.
modelBuilder.Entity<UserAccount>(entity =>
{
entity.Property(u => u.Email).IsRequired().IsUnique();
});Duplicate values are rejected against both existing rows and other pending inserts in the same SaveChangesAsync().
# Revert the most recently applied migration (runs its Down() and removes it from history)
dotnet sheetly migrations rollback
# Generate model + context classes from an existing spreadsheet
dotnet sheetly scaffoldYou can also inspect the schema currently applied to the store:
var snapshot = await context.Database.GetAppliedSchemaAsync();// Type-safe β compile-time validation
var products = await context.Products.Include(p => p.Category).ToListAsync();
var categories = await context.Categories.Include(c => c.Products).ToListAsync();
// String-based still supported
var products2 = await context.Products.Include("Category").ToListAsync();var products = await context.Products.ToListAsync();
products.First().Price = 999;
// No need for context.Products.Update(product) β changes are auto-detected
await context.SaveChangesAsync();Create every model table straight from your POCOs β no migrations required for a quick start or a throwaway spreadsheet:
await using var context = new AppDbContext(); // UseGoogleSheets/UseExcel in OnConfiguring
await context.Database.EnsureCreatedAsync(); // creates the sheets + schema if missing
context.Products.Add(new Product { Title = "Hello", Price = 9.99m });
await context.SaveChangesAsync();EnsureCreated and migrations are mutually exclusive (as in EF Core): if your project has migration
classes, use Database.MigrateAsync() / dotnet sheetly database update instead. Also available:
Database.EnsureDeletedAsync() and Database.CanConnectAsync().
Set a reference navigation and the foreign key fills itself in β even from a key generated in the same
SaveChanges. Principals are always written before their dependents.
var category = new Category { Name = "Tools" };
var product = new Product { Title = "Hammer", Category = category }; // no CategoryId set
context.Categories.Add(category);
context.Products.Add(product);
await context.SaveChangesAsync(); // category gets an Id; product.CategoryId is filled invar product = await context.Products.FindAsync(1);
product!.Price = 42m;
var entry = context.Entry(product);
Console.WriteLine(entry.State); // Modified (after DetectChanges)
Console.WriteLine(entry.OriginalValues["Price"]); // the loaded value
Console.WriteLine(entry.Property("Price").IsModified); // true
await entry.ReloadAsync(); // re-read this row from the store
if (context.ChangeTracker.HasChanges()) { /* ... */ }
context.ChangeTracker.Clear();options.UseGoogleSheets("spreadsheet-id", "credentials.json")
.LogTo(Console.WriteLine, SheetlyLogLevel.Debug);
// Logs SaveChanges summaries, Google API calls + 429 credential rotation, and Excel saves.services.AddSheetsContextFactory<AppDbContext>(o => o.UseGoogleSheets("id", "credentials.json"));
// later β real async initialization, no sync-over-async:
await using var context = await factory.CreateContextAsync(ct);// credentials.json can be a single object or an array:
// [{ "type": "service_account", ... }, { "type": "service_account", ... }]
// Each API call rotates to the next credential (round-robin); on a 429 the
// rotator advances to the next credential and retries.
options.UseGoogleSheets("spreadsheet-id", "credentials.json");Quota nuance. The Sheets API enforces two stacked limits (defaults): 60 req/min per service account per project and 300 req/min per project (read and write counted separately). A single account is therefore capped at 60/min, not 300. Rotating across multiple service accounts in the same project raises throughput from 60/min toward the 300/min project ceiling (β5 accounts max it out β more accounts in that project add nothing). To go beyond 300/min, the credentials must belong to separate Google Cloud projects; each project contributes its own 300/min ceiling.
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await context.SaveChangesAsync(cts.Token);Sheetly/
βββ Sheetly.Core # Core: context, sets, migrations, validation
βββ Sheetly.Google # Google Sheets API provider (online)
βββ Sheetly.Excel # Excel .xlsx provider (local)
βββ Sheetly.DependencyInjection # ASP.NET Core DI extensions
βββ dotnet-sheetly (CLI) # Command-line migration tool
Sheetly creates hidden sheets in your spreadsheet (Google Sheets or local .xlsx):
| Sheet | Purpose |
|---|---|
Products, Categories, ... |
Your data β one sheet per entity |
__SheetlySchema__ (hidden) |
Metadata: types, constraints, relationships, auto-increment IDs |
__SheetlyMigrationsHistory__ (hidden) |
Applied migration tracking |
Define models β Create migrations β Apply to Sheets β Use context
β β β β
C# classes .cs migration files Google Sheets CRUD + queries
- Never commit
credentials.jsonβ keep service-account keys in.gitignore, a secrets store, or environment-specific config. Ship a placeholder (credentials.example.json) instead. - Share with least privilege β give the service-account email Editor access to only the one spreadsheet Sheetly uses, nothing more.
- One service account per environment β separate keys for dev and production so a leaked dev key can't touch real data.
- All string values are written in Google's
RAWmode and stored verbatim, so user input β even something like=IMPORTXML(...)β is never evaluated as a live formula in your sheet.
Excel is a single-writer store. The
.xlsxprovider buffers writes and saves the whole file once perSaveChanges(or on dispose). It assumes one process/context writes the file at a time β concurrent writers to the same file are not supported. Use Google Sheets, or your own locking, for multi-writer scenarios.
Have a question, an idea, or something you built with Sheetly? Come say hi in GitHub Discussions. Found a bug? Open an issue.
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License β see the LICENSE file for details.
Created with β€οΈ by Muqimjon Mamadaliyev
Give it a β if you like it!