Class Based Views

Pagination

Pagination is essential for handling large datasets efficiently in web applications. Django provides robust pagination support through the Paginator class and built-in integration with class-based views like ListView.

Pagination

Pagination is essential for handling large datasets efficiently in web applications. Django provides robust pagination support through the Paginator class and built-in integration with class-based views like ListView.

The Paginator Class

Basic Paginator Usage

from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.shortcuts import render
from .models import Post

def basic_pagination_example(request):
    """Basic pagination with Paginator class"""
    posts = Post.objects.filter(status='published').order_by('-created_at')
    
    # Create paginator with 10 posts per page
    paginator = Paginator(posts, 10)
    
    # Get page number from request
    page_number = request.GET.get('page')
    
    try:
        page_obj = paginator.get_page(page_number)
    except PageNotAnInteger:
        # If page is not an integer, deliver first page
        page_obj = paginator.page(1)
    except EmptyPage:
        # If page is out of range, deliver last page
        page_obj = paginator.page(paginator.num_pages)
    
    return render(request, 'blog/post_list.html', {
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
    })

def advanced_paginator_example(request):
    """Advanced pagination with error handling"""
    posts = Post.objects.filter(status='published').select_related('author')
    
    # Create paginator with orphans handling
    paginator = Paginator(posts, per_page=15, orphans=3)
    
    page_number = request.GET.get('page', 1)
    
    # Use get_page() for automatic error handling
    page_obj = paginator.get_page(page_number)
    
    context = {
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
        'page_range': paginator.get_elided_page_range(
            page_obj.number, 
            on_each_side=2, 
            on_ends=1
        ),
    }
    
    return render(request, 'blog/post_list.html', context)

Paginator Properties and Methods

from django.core.paginator import Paginator

def paginator_properties_demo():
    """Demonstrate Paginator properties and methods"""
    posts = Post.objects.all()
    paginator = Paginator(posts, 10)
    
    # Paginator properties
    print(f"Total objects: {paginator.count}")
    print(f"Number of pages: {paginator.num_pages}")
    print(f"Page range: {list(paginator.page_range)}")
    print(f"Per page: {paginator.per_page}")
    
    # Get specific page
    page_1 = paginator.page(1)
    
    # Page object properties
    print(f"Page number: {page_1.number}")
    print(f"Has next: {page_1.has_next()}")
    print(f"Has previous: {page_1.has_previous()}")
    print(f"Next page number: {page_1.next_page_number() if page_1.has_next() else None}")
    print(f"Previous page number: {page_1.previous_page_number() if page_1.has_previous() else None}")
    print(f"Start index: {page_1.start_index()}")
    print(f"End index: {page_1.end_index()}")

class CustomPaginator(Paginator):
    """Custom paginator with additional features"""
    
    def __init__(self, object_list, per_page, **kwargs):
        self.show_all = kwargs.pop('show_all', False)
        super().__init__(object_list, per_page, **kwargs)
    
    def get_page(self, number):
        """Override to handle 'show all' functionality"""
        if self.show_all and str(number).lower() == 'all':
            # Return all objects in a single page
            return self.page(1)._replace(
                object_list=self.object_list,
                number=1,
                paginator=self._replace(num_pages=1)
            )
        
        return super().get_page(number)
    
    def get_elided_page_range_with_context(self, number, **kwargs):
        """Enhanced page range with context information"""
        page_range = self.get_elided_page_range(number, **kwargs)
        
        return {
            'page_range': page_range,
            'current_page': number,
            'total_pages': self.num_pages,
            'has_ellipsis': '' in page_range,
        }

def custom_paginator_example(request):
    """Using custom paginator"""
    posts = Post.objects.filter(status='published')
    
    # Allow showing all results
    show_all = request.GET.get('show_all') == 'true'
    
    paginator = CustomPaginator(
        posts, 
        per_page=20, 
        orphans=5,
        show_all=show_all
    )
    
    page_number = request.GET.get('page', 1)
    if show_all:
        page_number = 'all'
    
    page_obj = paginator.get_page(page_number)
    
    return render(request, 'blog/post_list.html', {
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
        'show_all': show_all,
    })

Paginating a ListView

Basic ListView Pagination

from django.views.generic import ListView
from django.core.paginator import Paginator

class PostListView(ListView):
    """Basic paginated ListView"""
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts'
    paginate_by = 10
    paginate_orphans = 3
    ordering = ['-created_at']
    
    def get_queryset(self):
        """Filter published posts only"""
        return Post.objects.filter(
            status='published'
        ).select_related('author', 'category')

class CategoryPostListView(ListView):
    """Paginated posts by category"""
    model = Post
    template_name = 'blog/category_posts.html'
    context_object_name = 'posts'
    paginate_by = 15
    
    def get_queryset(self):
        category_slug = self.kwargs['category_slug']
        return Post.objects.filter(
            category__slug=category_slug,
            status='published'
        ).select_related('author', 'category')
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        
        # Add category to context
        category_slug = self.kwargs['category_slug']
        context['category'] = get_object_or_404(Category, slug=category_slug)
        
        return context

class SearchPostListView(ListView):
    """Paginated search results"""
    model = Post
    template_name = 'blog/search_results.html'
    context_object_name = 'posts'
    paginate_by = 20
    
    def get_queryset(self):
        query = self.request.GET.get('q', '')
        
        if not query:
            return Post.objects.none()
        
        return Post.objects.filter(
            Q(title__icontains=query) | 
            Q(content__icontains=query),
            status='published'
        ).distinct().select_related('author')
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        query = self.request.GET.get('q', '')
        
        context.update({
            'query': query,
            'total_results': self.get_queryset().count() if query else 0,
        })
        
        return context

Advanced ListView Pagination

class AdvancedPostListView(ListView):
    """Advanced pagination with dynamic per_page"""
    model = Post
    template_name = 'blog/advanced_list.html'
    context_object_name = 'posts'
    paginate_by = 20
    paginate_orphans = 5
    
    def get_paginate_by(self, queryset):
        """Dynamic pagination based on user preference"""
        per_page = self.request.GET.get('per_page')
        
        if per_page:
            try:
                per_page = int(per_page)
                # Limit between 5 and 100
                return max(5, min(per_page, 100))
            except ValueError:
                pass
        
        # Check user preference
        if self.request.user.is_authenticated:
            profile = getattr(self.request.user, 'profile', None)
            if profile and profile.posts_per_page:
                return profile.posts_per_page
        
        return self.paginate_by
    
    def get_queryset(self):
        queryset = Post.objects.filter(
            status='published'
        ).select_related('author', 'category')
        
        # Apply sorting
        sort_by = self.request.GET.get('sort', 'newest')
        
        if sort_by == 'oldest':
            queryset = queryset.order_by('created_at')
        elif sort_by == 'title':
            queryset = queryset.order_by('title')
        elif sort_by == 'author':
            queryset = queryset.order_by('author__username', 'title')
        elif sort_by == 'popular':
            queryset = queryset.order_by('-views', '-created_at')
        else:  # newest
            queryset = queryset.order_by('-created_at')
        
        return queryset
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        
        # Add pagination info
        page_obj = context.get('page_obj')
        if page_obj:
            context.update({
                'current_page': page_obj.number,
                'total_pages': page_obj.paginator.num_pages,
                'total_items': page_obj.paginator.count,
                'start_index': page_obj.start_index(),
                'end_index': page_obj.end_index(),
                'per_page_options': [10, 20, 50, 100],
                'current_per_page': self.get_paginate_by(None),
                'page_range': self.get_page_range(page_obj),
            })
        
        # Add current sort
        context['current_sort'] = self.request.GET.get('sort', 'newest')
        
        return context
    
    def get_page_range(self, page_obj):
        """Get smart page range for pagination"""
        paginator = page_obj.paginator
        current_page = page_obj.number
        
        # Use elided page range for large page counts
        if paginator.num_pages > 10:
            return paginator.get_elided_page_range(
                current_page,
                on_each_side=2,
                on_ends=1
            )
        
        return paginator.page_range

class AjaxPostListView(ListView):
    """AJAX-enabled pagination"""
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts'
    paginate_by = 12
    
    def get_queryset(self):
        return Post.objects.filter(
            status='published'
        ).select_related('author')
    
    def render_to_response(self, context, **response_kwargs):
        """Handle AJAX pagination requests"""
        if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
            # Return JSON for AJAX requests
            page_obj = context.get('page_obj')
            
            posts_data = []
            for post in context['posts']:
                posts_data.append({
                    'id': post.id,
                    'title': post.title,
                    'excerpt': post.excerpt,
                    'author': post.author.username,
                    'created_at': post.created_at.strftime('%B %d, %Y'),
                    'url': post.get_absolute_url(),
                    'featured_image': post.featured_image.url if post.featured_image else None,
                })
            
            pagination_data = {
                'posts': posts_data,
                'pagination': {
                    'current_page': page_obj.number if page_obj else 1,
                    'total_pages': page_obj.paginator.num_pages if page_obj else 1,
                    'has_next': page_obj.has_next() if page_obj else False,
                    'has_previous': page_obj.has_previous() if page_obj else False,
                    'next_page_number': page_obj.next_page_number() if page_obj and page_obj.has_next() else None,
                    'previous_page_number': page_obj.previous_page_number() if page_obj and page_obj.has_previous() else None,
                    'total_items': page_obj.paginator.count if page_obj else 0,
                    'start_index': page_obj.start_index() if page_obj else 0,
                    'end_index': page_obj.end_index() if page_obj else 0,
                }
            }
            
            return JsonResponse(pagination_data)
        
        return super().render_to_response(context, **response_kwargs)

class InfiniteScrollListView(ListView):
    """Infinite scroll pagination"""
    model = Post
    template_name = 'blog/infinite_scroll.html'
    context_object_name = 'posts'
    paginate_by = 10
    
    def get_queryset(self):
        return Post.objects.filter(
            status='published'
        ).select_related('author')
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        
        # Add infinite scroll data
        page_obj = context.get('page_obj')
        if page_obj:
            context.update({
                'has_more': page_obj.has_next(),
                'next_page_url': f"?page={page_obj.next_page_number()}" if page_obj.has_next() else None,
                'current_page': page_obj.number,
            })
        
        return context
    
    def render_to_response(self, context, **response_kwargs):
        """Handle infinite scroll AJAX requests"""
        if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
            # Return partial template for AJAX
            self.template_name = 'blog/partials/post_list_items.html'
        
        return super().render_to_response(context, **response_kwargs)

Using Paginator in View Functions

Function-Based View Pagination

from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse
from django.db.models import Q, Count

def post_list_view(request):
    """Basic function-based view with pagination"""
    posts = Post.objects.filter(status='published').order_by('-created_at')
    
    paginator = Paginator(posts, 15)  # 15 posts per page
    page_number = request.GET.get('page')
    page_obj = paginator.get_page(page_number)
    
    context = {
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
    }
    
    return render(request, 'blog/post_list.html', context)

def filtered_post_list_view(request):
    """Function-based view with filtering and pagination"""
    posts = Post.objects.filter(status='published')
    
    # Apply filters
    category_slug = request.GET.get('category')
    if category_slug:
        posts = posts.filter(category__slug=category_slug)
    
    author_id = request.GET.get('author')
    if author_id:
        posts = posts.filter(author_id=author_id)
    
    tag_slug = request.GET.get('tag')
    if tag_slug:
        posts = posts.filter(tags__slug=tag_slug)
    
    search_query = request.GET.get('q')
    if search_query:
        posts = posts.filter(
            Q(title__icontains=search_query) |
            Q(content__icontains=search_query)
        )
    
    # Apply ordering
    sort_by = request.GET.get('sort', 'newest')
    if sort_by == 'oldest':
        posts = posts.order_by('created_at')
    elif sort_by == 'title':
        posts = posts.order_by('title')
    elif sort_by == 'popular':
        posts = posts.order_by('-views')
    else:
        posts = posts.order_by('-created_at')
    
    # Optimize query
    posts = posts.select_related('author', 'category').prefetch_related('tags')
    
    # Pagination
    per_page = request.GET.get('per_page', 20)
    try:
        per_page = int(per_page)
        per_page = max(5, min(per_page, 100))  # Between 5 and 100
    except ValueError:
        per_page = 20
    
    paginator = Paginator(posts, per_page)
    page_number = request.GET.get('page')
    page_obj = paginator.get_page(page_number)
    
    context = {
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
        'current_filters': {
            'category': category_slug,
            'author': author_id,
            'tag': tag_slug,
            'search': search_query,
            'sort': sort_by,
            'per_page': per_page,
        },
        'filter_options': get_filter_options(),
    }
    
    return render(request, 'blog/filtered_list.html', context)

def get_filter_options():
    """Get available filter options"""
    return {
        'categories': Category.objects.annotate(
            post_count=Count('posts', filter=Q(posts__status='published'))
        ).filter(post_count__gt=0),
        'authors': User.objects.filter(
            posts__status='published'
        ).annotate(post_count=Count('posts')).distinct(),
        'popular_tags': Tag.objects.annotate(
            post_count=Count('posts', filter=Q(posts__status='published'))
        ).filter(post_count__gt=0).order_by('-post_count')[:20],
    }

def ajax_post_list_view(request):
    """AJAX-enabled function-based pagination"""
    posts = Post.objects.filter(status='published').select_related('author')
    
    # Apply search if provided
    search_query = request.GET.get('q', '')
    if search_query:
        posts = posts.filter(
            Q(title__icontains=search_query) |
            Q(content__icontains=search_query)
        )
    
    posts = posts.order_by('-created_at')
    
    # Pagination
    paginator = Paginator(posts, 10)
    page_number = request.GET.get('page')
    page_obj = paginator.get_page(page_number)
    
    # Handle AJAX requests
    if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
        posts_data = []
        for post in page_obj:
            posts_data.append({
                'id': post.id,
                'title': post.title,
                'excerpt': post.excerpt,
                'author': post.author.get_full_name() or post.author.username,
                'created_at': post.created_at.strftime('%B %d, %Y'),
                'url': post.get_absolute_url(),
                'category': post.category.name if post.category else None,
            })
        
        return JsonResponse({
            'posts': posts_data,
            'pagination': {
                'current_page': page_obj.number,
                'total_pages': paginator.num_pages,
                'has_next': page_obj.has_next(),
                'has_previous': page_obj.has_previous(),
                'total_items': paginator.count,
                'start_index': page_obj.start_index(),
                'end_index': page_obj.end_index(),
            },
            'search_query': search_query,
        })
    
    # Regular template response
    context = {
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
        'search_query': search_query,
    }
    
    return render(request, 'blog/ajax_list.html', context)

def category_posts_view(request, category_slug):
    """Category-specific posts with pagination"""
    category = get_object_or_404(Category, slug=category_slug)
    
    posts = Post.objects.filter(
        category=category,
        status='published'
    ).select_related('author').order_by('-created_at')
    
    paginator = Paginator(posts, 12)
    page_number = request.GET.get('page')
    page_obj = paginator.get_page(page_number)
    
    context = {
        'category': category,
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
    }
    
    return render(request, 'blog/category_posts.html', context)

def user_posts_view(request, username):
    """User-specific posts with pagination"""
    author = get_object_or_404(User, username=username)
    
    posts = Post.objects.filter(
        author=author,
        status='published'
    ).select_related('category').order_by('-created_at')
    
    paginator = Paginator(posts, 10)
    page_number = request.GET.get('page')
    page_obj = paginator.get_page(page_number)
    
    # Get author statistics
    author_stats = {
        'total_posts': posts.count(),
        'total_views': posts.aggregate(total_views=Sum('views'))['total_views'] or 0,
        'categories': posts.values('category__name').annotate(
            count=Count('id')
        ).order_by('-count')[:5],
    }
    
    context = {
        'author': author,
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
        'author_stats': author_stats,
    }
    
    return render(request, 'blog/user_posts.html', context)

Advanced Pagination Patterns

def paginated_api_view(request):
    """API endpoint with pagination"""
    posts = Post.objects.filter(status='published').select_related('author')
    
    # Get pagination parameters
    page = request.GET.get('page', 1)
    per_page = request.GET.get('per_page', 20)
    
    try:
        per_page = int(per_page)
        per_page = max(1, min(per_page, 100))  # Limit between 1 and 100
    except ValueError:
        per_page = 20
    
    paginator = Paginator(posts, per_page)
    page_obj = paginator.get_page(page)
    
    # Serialize posts
    posts_data = []
    for post in page_obj:
        posts_data.append({
            'id': post.id,
            'title': post.title,
            'slug': post.slug,
            'excerpt': post.excerpt,
            'author': {
                'id': post.author.id,
                'username': post.author.username,
                'full_name': post.author.get_full_name(),
            },
            'created_at': post.created_at.isoformat(),
            'updated_at': post.updated_at.isoformat(),
            'url': request.build_absolute_uri(post.get_absolute_url()),
        })
    
    # Build pagination links
    base_url = request.build_absolute_uri(request.path)
    
    def build_page_url(page_num):
        params = request.GET.copy()
        params['page'] = page_num
        return f"{base_url}?{params.urlencode()}"
    
    pagination_links = {
        'first': build_page_url(1),
        'last': build_page_url(paginator.num_pages),
        'next': build_page_url(page_obj.next_page_number()) if page_obj.has_next() else None,
        'previous': build_page_url(page_obj.previous_page_number()) if page_obj.has_previous() else None,
    }
    
    response_data = {
        'posts': posts_data,
        'pagination': {
            'current_page': page_obj.number,
            'per_page': per_page,
            'total_pages': paginator.num_pages,
            'total_items': paginator.count,
            'has_next': page_obj.has_next(),
            'has_previous': page_obj.has_previous(),
            'links': pagination_links,
        }
    }
    
    return JsonResponse(response_data)

def cursor_paginated_view(request):
    """Cursor-based pagination for real-time feeds"""
    cursor = request.GET.get('cursor')
    limit = min(int(request.GET.get('limit', 20)), 100)
    
    posts = Post.objects.filter(status='published').order_by('-created_at')
    
    if cursor:
        try:
            # Decode cursor (base64 encoded timestamp)
            import base64
            from datetime import datetime
            cursor_time = datetime.fromisoformat(
                base64.b64decode(cursor).decode('utf-8')
            )
            posts = posts.filter(created_at__lt=cursor_time)
        except (ValueError, TypeError):
            pass  # Invalid cursor, ignore
    
    # Get one extra to check if there are more results
    posts_list = list(posts[:limit + 1])
    has_more = len(posts_list) > limit
    
    if has_more:
        posts_list = posts_list[:limit]
    
    # Generate next cursor
    next_cursor = None
    if has_more and posts_list:
        last_post = posts_list[-1]
        next_cursor = base64.b64encode(
            last_post.created_at.isoformat().encode('utf-8')
        ).decode('utf-8')
    
    # Serialize posts
    posts_data = []
    for post in posts_list:
        posts_data.append({
            'id': post.id,
            'title': post.title,
            'created_at': post.created_at.isoformat(),
            'author': post.author.username,
        })
    
    return JsonResponse({
        'posts': posts_data,
        'pagination': {
            'has_more': has_more,
            'next_cursor': next_cursor,
            'limit': limit,
        }
    })

def bulk_paginated_view(request):
    """Pagination with bulk operations"""
    posts = Post.objects.filter(status='published').select_related('author')
    
    # Handle bulk actions
    if request.method == 'POST':
        action = request.POST.get('action')
        selected_ids = request.POST.getlist('selected_posts')
        
        if action == 'bulk_delete' and selected_ids:
            Post.objects.filter(
                id__in=selected_ids,
                author=request.user  # Only allow deleting own posts
            ).delete()
            
        elif action == 'bulk_feature' and selected_ids:
            Post.objects.filter(
                id__in=selected_ids,
                author=request.user
            ).update(featured=True)
        
        # Redirect to avoid resubmission
        return redirect(request.path)
    
    # Pagination
    paginator = Paginator(posts, 25)
    page_number = request.GET.get('page')
    page_obj = paginator.get_page(page_number)
    
    context = {
        'posts': page_obj,
        'paginator': paginator,
        'page_obj': page_obj,
        'bulk_actions': [
            ('bulk_delete', 'Delete Selected'),
            ('bulk_feature', 'Feature Selected'),
        ],
    }
    
    return render(request, 'blog/bulk_list.html', context)

Example Templates

Basic Pagination Template

<!-- blog/post_list.html -->
<div class="post-list">
    {% for post in posts %}
        <article class="post-item">
            <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
            <p class="post-meta">
                By {{ post.author.get_full_name|default:post.author.username }}
                on {{ post.created_at|date:"F d, Y" }}
            </p>
            <p>{{ post.excerpt }}</p>
        </article>
    {% empty %}
        <p>No posts found.</p>
    {% endfor %}
</div>

<!-- Pagination -->
{% if page_obj.has_other_pages %}
    <nav class="pagination" aria-label="Page navigation">
        <ul class="pagination-list">
            {% if page_obj.has_previous %}
                <li>
                    <a href="?page=1" class="pagination-link" aria-label="First page">
                        &laquo; First
                    </a>
                </li>
                <li>
                    <a href="?page={{ page_obj.previous_page_number }}" 
                       class="pagination-link" aria-label="Previous page">
                        &lsaquo; Previous
                    </a>
                </li>
            {% endif %}
            
            {% for page_num in page_obj.paginator.page_range %}
                {% if page_num == page_obj.number %}
                    <li>
                        <span class="pagination-link current" aria-current="page">
                            {{ page_num }}
                        </span>
                    </li>
                {% else %}
                    <li>
                        <a href="?page={{ page_num }}" class="pagination-link">
                            {{ page_num }}
                        </a>
                    </li>
                {% endif %}
            {% endfor %}
            
            {% if page_obj.has_next %}
                <li>
                    <a href="?page={{ page_obj.next_page_number }}" 
                       class="pagination-link" aria-label="Next page">
                        Next &rsaquo;
                    </a>
                </li>
                <li>
                    <a href="?page={{ page_obj.paginator.num_pages }}" 
                       class="pagination-link" aria-label="Last page">
                        Last &raquo;
                    </a>
                </li>
            {% endif %}
        </ul>
    </nav>
    
    <div class="pagination-info">
        Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} 
        of {{ page_obj.paginator.count }} posts
    </div>
{% endif %}

Advanced Pagination Template

<!-- blog/advanced_pagination.html -->
{% load custom_tags %}

<div class="pagination-controls">
    <!-- Per page selector -->
    <div class="per-page-selector">
        <label for="per-page">Posts per page:</label>
        <select id="per-page" onchange="changePerPage(this.value)">
            {% for option in per_page_options %}
                <option value="{{ option }}" 
                        {% if option == current_per_page %}selected{% endif %}>
                    {{ option }}
                </option>
            {% endfor %}
        </select>
    </div>
    
    <!-- Page info -->
    <div class="page-info">
        Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
        ({{ page_obj.paginator.count }} total posts)
    </div>
</div>

<!-- Smart pagination with elided ranges -->
{% if page_obj.has_other_pages %}
    <nav class="smart-pagination">
        <ul class="pagination-list">
            {% if page_obj.has_previous %}
                <li><a href="?{% url_params page=1 %}">&laquo;</a></li>
                <li><a href="?{% url_params page=page_obj.previous_page_number %}">&lsaquo;</a></li>
            {% endif %}
            
            {% for page_num in page_range %}
                {% if page_num == page_obj.number %}
                    <li><span class="current">{{ page_num }}</span></li>
                {% elif page_num == '…' %}
                    <li><span class="ellipsis"></span></li>
                {% else %}
                    <li><a href="?{% url_params page=page_num %}">{{ page_num }}</a></li>
                {% endif %}
            {% endfor %}
            
            {% if page_obj.has_next %}
                <li><a href="?{% url_params page=page_obj.next_page_number %}">&rsaquo;</a></li>
                <li><a href="?{% url_params page=page_obj.paginator.num_pages %}">&raquo;</a></li>
            {% endif %}
        </ul>
    </nav>
{% endif %}

<!-- AJAX pagination -->
<script>
function changePerPage(perPage) {
    const url = new URL(window.location);
    url.searchParams.set('per_page', perPage);
    url.searchParams.set('page', '1'); // Reset to first page
    window.location.href = url.toString();
}

// AJAX page loading
document.addEventListener('click', function(e) {
    if (e.target.matches('.pagination-list a')) {
        e.preventDefault();
        loadPage(e.target.href);
    }
});

function loadPage(url) {
    fetch(url, {
        headers: {
            'X-Requested-With': 'XMLHttpRequest'
        }
    })
    .then(response => response.json())
    .then(data => {
        updatePostList(data.posts);
        updatePagination(data.pagination);
        
        // Update URL without page reload
        history.pushState(null, '', url);
    })
    .catch(error => {
        console.error('Error loading page:', error);
    });
}

function updatePostList(posts) {
    const container = document.querySelector('.post-list');
    container.innerHTML = posts.map(post => `
        <article class="post-item">
            <h2><a href="${post.url}">${post.title}</a></h2>
            <p class="post-meta">By ${post.author} on ${post.created_at}</p>
            <p>${post.excerpt}</p>
        </article>
    `).join('');
}

function updatePagination(pagination) {
    // Update pagination controls based on pagination data
    // Implementation depends on your specific needs
}
</script>

Django's pagination system provides flexible tools for handling large datasets efficiently. The Paginator class offers fine-grained control, while ListView provides convenient built-in pagination. Understanding these tools enables you to create responsive, user-friendly interfaces for browsing large collections of data.