Database (PostgreSQL)
Working with PostgreSQL in Supabase — tables, CRUD operations, queries, and relationships.
Supabase uses PostgreSQL as its database — the world's most advanced open-source relational database. Unlike Firestore (NoSQL), PostgreSQL is a relational database with:
- Tables – Structured data with rows and columns
- Relationships – Foreign keys and JOINs
- SQL – Powerful query language
- Transactions – ACID compliance
- Indexes – Fast queries
- Constraints – Data integrity
✅ PostgreSQL vs Firestore (NoSQL)
- PostgreSQL – Relational, complex queries, transactions, strong consistency
- Firestore – Document-based, simpler queries, eventual consistency
- PostgreSQL – Better for complex data relationships
- Firestore – Better for simple, hierarchical data
A database schema defines the structure of your data. Here's a common schema for a blog app:
-- Users table (extends Supabase auth.users)
CREATE TABLE public.profiles (
id UUID REFERENCES auth.users(id) PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Posts table
CREATE TABLE public.posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Comments table
CREATE TABLE public.comments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Likes table
CREATE TABLE public.likes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(post_id, user_id)
);
Supabase provides a fluent API for CRUD operations. Here's how to perform them in Flutter.
import
'package:supabase_flutter/supabase_flutter.dart'
;
class
PostService
{
final
SupabaseClient client = Supabase.instance.client;
// ----- CREATE (INSERT) -----
Future
<Map<String, dynamic>>
createPost
({
required
String userId,
required
String title,
required
String content,
}) async {
final
response =
await
client
.from(
'posts'
)
.insert({
'user_id'
: userId,
'title'
: title,
'content'
: content,
})
.select()
.single();
return
response;
}
// ----- READ (SELECT) -----
Future
<List<Map<String, dynamic>>>
getAllPosts
() async {
final
response =
await
client
.from(
'posts'
)
.select(
'*, profiles(username, full_name)'
)
.order(
'created_at'
, ascending:
false
);
return
response;
}
Future
<Map<String, dynamic>>
getPostById
(String id) async {
final
response =
await
client
.from(
'posts'
)
.select(
'*, profiles(username, full_name), comments(*, profiles(username))'
)
.eq(
'id'
, id)
.single();
return
response;
}
// ----- UPDATE -----
Future
<Map<String, dynamic>>
updatePost
({
required
String id,
required
String title,
required
String content,
}) async {
final
response =
await
client
.from(
'posts'
)
.update({
'title'
: title,
'content'
: content,
'updated_at'
: DateTime.now().toIso8601String(),
})
.eq(
'id'
, id)
.select()
.single();
return
response;
}
// ----- DELETE -----
Future
<
void
>
deletePost
(String id) async {
await
client
.from(
'posts'
)
.delete()
.eq(
'id'
, id);
}
}
Supabase supports powerful query operations including filtering, sorting, and limiting.
class
QueryService
{
final
SupabaseClient client = Supabase.instance.client;
// ----- FILTERING -----
Future
<List<Map<String, dynamic>>>
filterPosts
({
String? userId,
String? searchQuery,
DateTime? fromDate,
DateTime? toDate,
}) async {
var
query = client.from(
'posts'
).select(
'*, profiles(username)'
);
if
(userId !=
null
) {
query = query.eq(
'user_id'
, userId);
}
if
(searchQuery !=
null
&& searchQuery.isNotEmpty) {
query = query.ilike(
'title'
,
'%$searchQuery%'
);
}
if
(fromDate !=
null
) {
query = query.gte(
'created_at'
, fromDate.toIso8601String());
}
if
(toDate !=
null
) {
query = query.lte(
'created_at'
, toDate.toIso8601String());
}
return
await
query.order(
'created_at'
, ascending:
false
);
}
// ----- PAGINATION -----
Future
<List<Map<String, dynamic>>>
getPostsPaginated
({
required
int
page,
required
int
pageSize,
}) async {
final
from = (page -
1
) * pageSize;
final
to = from + pageSize -
1
;
return
await
client
.from(
'posts'
)
.select(
'*, profiles(username)'
)
.order(
'created_at'
, ascending:
false
)
.range(from, to);
}
// ----- AGGREGATION -----
Future
<Map<String, dynamic>>
getPostStats
() async {
// Get total posts
final
countResponse =
await
client
.from(
'posts'
)
.select(
'*'
, count: CountOption.exact)
.limit(
0
);
// Get posts by user
final
userPosts =
await
client
.from(
'posts'
)
.select(
'user_id, count'
)
.groupBy(
'user_id'
);
return
{
'totalPosts'
: countResponse.count,
'postsByUser'
: userPosts,
};
}
// ----- COMPLEX QUERY WITH JOIN -----
Future
<List<Map<String, dynamic>>>
getPostsWithCommentsCount
() async {
return
await
client
.from(
'posts'
)
.select(
'*, profiles(username), comments(count)'
)
.order(
'created_at'
, ascending:
false
);
}
}
PostgreSQL supports powerful relationships between tables using foreign keys.
// One-to-Many: User → Posts
final
userWithPosts =
await
client
.from(
'profiles'
)
.select(
'*, posts(*)'
)
.eq(
'id'
, userId)
.single();
// Many-to-One: Post → User (with foreign key)
final
postWithUser =
await
client
.from(
'posts'
)
.select(
'*, profiles(username, full_name)'
)
.eq(
'id'
, postId)
.single();
// Many-to-Many: Posts ↔ Users (through likes table)
final
likedPosts =
await
client
.from(
'likes'
)
.select(
'posts(*, profiles(username))'
)
.eq(
'user_id'
, userId)
.order(
'created_at'
, ascending:
false
);
🔑 Foreign Key Types
- ON DELETE CASCADE – Delete child rows when parent is deleted
- ON DELETE SET NULL – Set foreign key to NULL when parent is deleted
- ON DELETE RESTRICT – Prevent deletion if child rows exist
Here's a complete blog app using Supabase PostgreSQL with all CRUD operations.
import
'package:flutter/material.dart'
;
import
'package:supabase_flutter/supabase_flutter.dart'
;
import
'package:intl/intl.dart'
;
// ==========================================
// 1. MAIN
// ==========================================
void
main
() async {
await
Supabase.initialize(
url:
'https://your-project.supabase.co'
,
anonKey:
'your-anon-key'
,
);
runApp(
MyApp
());
}
class
MyApp
extends
StatelessWidget {
@override
Widget
build
(BuildContext context) {
return
MaterialApp(
title:
'Blog App'
,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3:
true
,
),
home: BlogListScreen(),
);
}
}
// ==========================================
// 2. SERVICES
// ==========================================
class
BlogService
{
final
SupabaseClient client = Supabase.instance.client;
Future
<List<Map<String, dynamic>>>
getPosts
() async {
final
response =
await
client
.from(
'posts'
)
.select(
'*, profiles(username, full_name)'
)
.order(
'created_at'
, ascending:
false
);
return
response;
}
Future
<Map<String, dynamic>>
createPost
({
required
String userId,
required
String title,
required
String content,
}) async {
return
await
client
.from(
'posts'
)
.insert({
'user_id'
: userId,
'title'
: title,
'content'
: content,
})
.select()
.single();
}
Future
<
void
>
deletePost
(String id) async {
await
client.from(
'posts'
).delete().eq(
'id'
, id);
}
Future
<Map<String, dynamic>>
toggleLike
({
required
String postId,
required
String userId,
}) async {
// Check if like exists
final
existing =
await
client
.from(
'likes'
)
.select()
.eq(
'post_id'
, postId)
.eq(
'user_id'
, userId)
.maybeSingle();
if
(existing !=
null
) {
// Unlike
await
client
.from(
'likes'
)
.delete()
.eq(
'post_id'
, postId)
.eq(
'user_id'
, userId);
return
{
'liked'
:
false
};
}
else
{
// Like
await
client
.from(
'likes'
)
.insert({
'post_id'
: postId,
'user_id'
: userId,
});
return
{
'liked'
:
true
};
}
}
}
// ==========================================
// 3. UI
// ==========================================
class
BlogListScreen
extends
StatefulWidget {
@override
State<BlogListScreen>
createState
() => _BlogListScreenState();
}
class
_BlogListScreenState
extends
State<BlogListScreen> {
final
BlogService _blogService = BlogService();
List<Map<String, dynamic>> _posts = [];
bool
_isLoading =
true
;
bool
_hasError =
false
;
String? _errorMessage;
final
TextEditingController _titleController = TextEditingController();
final
TextEditingController _contentController = TextEditingController();
@override
void
initState
() {
super
.initState();
_loadPosts();
}
Future
<
void
>
_loadPosts
() async {
setState(() {
_isLoading =
true
;
_hasError =
false
;
});
try
{
final
posts =
await
_blogService.getPosts();
setState(() {
_posts = posts;
_isLoading =
false
;
});
}
catch
(e) {
setState(() {
_isLoading =
false
;
_hasError =
true
;
_errorMessage = e.toString();
});
}
}
Future
<
void
>
_createPost
() async {
final
title = _titleController.text.trim();
final
content = _contentController.text.trim();
if
(title.isEmpty || content.isEmpty)
return
;
final
user = Supabase.instance.client.auth.currentUser;
if
(user ==
null
)
return
;
try
{
await
_blogService.createPost(
userId: user.id,
title: title,
content: content,
);
_titleController.clear();
_contentController.clear();
await
_loadPosts();
Navigator.pop(context);
}
catch
(e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(
'Error: $e'
), backgroundColor: Colors.red),
);
}
}
Future
<
void
>
_deletePost
(String id) async {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Delete Post'
),
content: Text(
'Are you sure you want to delete this post?'
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Cancel'
),
),
ElevatedButton(
onPressed: () async {
await
_blogService.deletePost(id);
Navigator.pop(context);
await
_loadPosts();
},
child: Text(
'Delete'
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
),
],
),
);
}
Future
<
void
>
_toggleLike
(String postId) async {
final
user = Supabase.instance.client.auth.currentUser;
if
(user ==
null
)
return
;
await
_blogService.toggleLike(postId: postId, userId: user.id);
await
_loadPosts();
}
void
_showCreateDialog
() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Create Post'
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _titleController,
decoration: InputDecoration(
hintText:
'Title...'
,
border: OutlineInputBorder(),
),
),
SizedBox(height:
8
),
TextField(
controller: _contentController,
decoration: InputDecoration(
hintText:
'Content...'
,
border: OutlineInputBorder(),
),
maxLines:
5
,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Cancel'
),
),
ElevatedButton(
onPressed: _createPost,
child: Text(
'Publish'
),
),
],
),
);
}
@override
Widget
build
(BuildContext context) {
return
Scaffold(
appBar: AppBar(
title: Text(
'📝 Blog'
),
centerTitle:
true
,
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: _loadPosts,
),
],
),
body: _isLoading
? Center(child: CircularProgressIndicator())
: _hasError
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size:
64
, color: Colors.red),
SizedBox(height:
16
),
Text(_errorMessage ??
'Error loading posts'
),
SizedBox(height:
16
),
ElevatedButton(
onPressed: _loadPosts,
child: Text(
'Retry'
),
),
],
),
)
: _posts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.post_add, size:
80
, color: Colors.grey.shade400),
SizedBox(height:
16
),
Text(
'No posts yet'
,
style: TextStyle(fontSize:
20
, fontWeight: FontWeight.w600),
),
Text(
'Be the first to publish!'
,
style: TextStyle(color: Colors.grey.shade400),
),
],
),
)
: ListView.builder(
padding: EdgeInsets.all(
8
),
itemCount: _posts.length,
itemBuilder: (context, index) {
final
post = _posts[index];
final
profile = post[
'profiles'
]
as
Map<String, dynamic>?;
final
username = profile?[
'username'
] ??
'Unknown'
;
final
createdAt = DateTime.parse(post[
'created_at'
]);
return
Card(
margin: EdgeInsets.symmetric(horizontal:
8
, vertical:
4
),
child: Padding(
padding: EdgeInsets.all(
12
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius:
16
,
child: Text(
username.isNotEmpty ? username[
0
].toUpperCase() :
'?'
,
style: TextStyle(fontSize:
12
),
),
),
SizedBox(width:
8
),
Text(
username,
style: TextStyle(fontWeight: FontWeight.w600),
),
Spacer(),
Text(
DateFormat('MMM d, yyyy').format(createdAt),
style: TextStyle(fontSize:
12
, color: Colors.grey.shade500),
),
],
),
SizedBox(height:
8
),
Text(
post[
'title'
],
style: TextStyle(fontSize:
18
, fontWeight: FontWeight.bold),
),
SizedBox(height:
4
),
Text(
post[
'content'
],
style: TextStyle(color: Colors.grey.shade700),
maxLines:
3
,
overflow: TextOverflow.ellipsis,
),
SizedBox(height:
12
),
Row(
children: [
IconButton(
icon: Icon(Icons.favorite_border, size:
20
),
onPressed: () => _toggleLike(post[
'id'
]),
),
SizedBox(width:
4
),
Text(
'0',
// Like count would come from the database
style: TextStyle(fontSize:
14
),
),
Spacer(),
IconButton(
icon: Icon(Icons.delete, size:
20
, color: Colors.red),
onPressed: () => _deletePost(post[
'id'
]),
),
],
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _showCreateDialog,
child: Icon(Icons.add),
),
);
}
}
.insert()
with
.select()
to create and return data.
.select()
with relationships using dot notation (
'*, profiles(username)'
).
.update()
with
.eq()
and
.select()
.
.delete()
with
.eq()
to remove rows.
Without
.select()
, you won't get the created/updated data back with default values like
created_at
.
.insert(data).select().single()
returns the newly created row.
When using
.select('*, profiles(username)')
, the
profiles
field may be null if no related profile exists.
Use
profile?['username'] ?? 'Unknown'
to handle null values safely.
Without Row Level Security, users can read and modify data they shouldn't have access to.
Enable RLS in the Supabase dashboard and create policies for each table and operation.
🎯 Key Takeaway
Supabase uses PostgreSQL for data storage, providing powerful relational database capabilities. You can perform CRUD operations with a fluent API, complex queries with filtering and sorting, and relationships with foreign keys. Always use .select() after mutations to get the updated data.